Merge master and update toastApiRef to new ApiRef pattern
Resolve merge conflicts from master introducing the new ApiRef_2 type
pattern with `.with()` builder. Update toastApiRef to use the same
`createApiRef<T>().with({ id, pluginId })` pattern as all other API
refs, and regenerate API reports.
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
@@ -23,6 +23,9 @@ import {
|
||||
PageBlueprint,
|
||||
FrontendPluginInfo,
|
||||
useAppNode,
|
||||
createExtensionBlueprint,
|
||||
createExtensionInput,
|
||||
coreExtensionData,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
@@ -65,7 +68,8 @@ const IndexPage = PageBlueprint.make({
|
||||
const page1Link = useRouteRef(page1RouteRef);
|
||||
return (
|
||||
<div>
|
||||
op
|
||||
<h1>Example Pages Plugin</h1>
|
||||
<h2>Navigation</h2>
|
||||
{page1Link && (
|
||||
<div>
|
||||
<Link to={page1Link()}>Page 1</Link>
|
||||
@@ -83,6 +87,54 @@ const IndexPage = PageBlueprint.make({
|
||||
<div>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</div>
|
||||
|
||||
<h2>Permission Enablement Examples</h2>
|
||||
<p>
|
||||
The following pages demonstrate conditional extension enablement
|
||||
via the <code>if</code> predicate using permissions. They will
|
||||
only appear when the user has the required permissions.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/permission-gated-example">
|
||||
Permission Gated Example
|
||||
</Link>{' '}
|
||||
— requires <code>catalog.entity.create</code>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/permission-card-example">
|
||||
Permission Card Example
|
||||
</Link>{' '}
|
||||
— a page that is always visible, but individual cards on it are
|
||||
toggled by permissions
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Feature Flag Enablement Examples</h2>
|
||||
<p>
|
||||
The following pages demonstrate conditional extension enablement
|
||||
via the <code>if</code> predicate. They will only appear in the
|
||||
router tree when their conditions are satisfied. Toggle the
|
||||
relevant feature flags in <Link to="/settings">Settings</Link>,
|
||||
then refresh the app to see the pages appear.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/feature-flag-example">Feature Flag Example</Link> —
|
||||
requires the <code>experimental-features</code> flag
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/all-flags-example">All Flags Example</Link> —
|
||||
requires <em>both</em> <code>experimental-features</code> and{' '}
|
||||
<code>advanced-features</code> (<code>$all</code>)
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/any-flag-example">Any Flag Example</Link> — requires{' '}
|
||||
<em>either</em> <code>experimental-features</code> or{' '}
|
||||
<code>beta-access</code> (<code>$any</code>)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<PluginInfo />
|
||||
</div>
|
||||
);
|
||||
@@ -150,6 +202,270 @@ const ExternalPage = PageBlueprint.make({
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page enabled only when a single feature flag is active.
|
||||
//
|
||||
// The `if` predicate is evaluated once at app startup (before the router
|
||||
// tree is built), so this page simply won't exist in the app until the flag is
|
||||
// toggled and the page is refreshed.
|
||||
//
|
||||
// To test: enable the 'experimental-features' flag in Settings, then refresh.
|
||||
const FeatureFlagPage = PageBlueprint.make({
|
||||
name: 'featureFlagExample',
|
||||
params: {
|
||||
path: '/feature-flag-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Feature Flag Enabled Page</h1>
|
||||
<p>
|
||||
This page is only present in the app when the{' '}
|
||||
<code>experimental-features</code> feature flag is active.
|
||||
</p>
|
||||
<p>
|
||||
It uses a simple{' '}
|
||||
<code>
|
||||
{'{ featureFlags: { $contains: "experimental-features" } }'}
|
||||
</code>{' '}
|
||||
predicate.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
if: { featureFlags: { $contains: 'experimental-features' } },
|
||||
});
|
||||
|
||||
// Example: Page enabled only when ALL of several feature flags are active.
|
||||
//
|
||||
// The $all operator requires every nested predicate to be satisfied. This page
|
||||
// won't appear unless both 'experimental-features' and 'advanced-features' are
|
||||
// enabled at the same time.
|
||||
//
|
||||
// To test: enable BOTH flags in Settings, then refresh.
|
||||
const AllFlagsPage = PageBlueprint.make({
|
||||
name: 'allFlagsExample',
|
||||
params: {
|
||||
path: '/all-flags-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>All Flags Required Page</h1>
|
||||
<p>
|
||||
This page requires <em>both</em>{' '}
|
||||
<code>experimental-features</code> and{' '}
|
||||
<code>advanced-features</code> to be active simultaneously.
|
||||
</p>
|
||||
<p>
|
||||
It uses a <code>$all</code> predicate to AND the two conditions
|
||||
together.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
if: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page enabled when ANY one of several feature flags is active.
|
||||
//
|
||||
// The $any operator is satisfied as soon as at least one nested predicate
|
||||
// matches. Enabling either 'experimental-features' or 'beta-access' will make
|
||||
// this page appear.
|
||||
//
|
||||
// To test: enable at least one of the two flags in Settings, then refresh.
|
||||
const AnyFlagPage = PageBlueprint.make({
|
||||
name: 'anyFlagExample',
|
||||
params: {
|
||||
path: '/any-flag-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Any Flag Sufficient Page</h1>
|
||||
<p>
|
||||
This page appears when <em>either</em>{' '}
|
||||
<code>experimental-features</code> or <code>beta-access</code> is
|
||||
active.
|
||||
</p>
|
||||
<p>
|
||||
It uses a <code>$any</code> predicate to OR the two conditions
|
||||
together.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
if: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Blueprint for cards that attach to the PermissionCardPage below.
|
||||
//
|
||||
// Each card receives a title and description and renders a simple bordered card.
|
||||
// Individual card instances can be selectively enabled via the `if`
|
||||
// predicate, so only the cards the user is allowed to see will be instantiated.
|
||||
const PermissionExampleCardBlueprint = createExtensionBlueprint({
|
||||
kind: 'permission-example-card',
|
||||
attachTo: { id: 'page:pages/permissionCardExample', input: 'cards' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
*factory(params: { title: string; description: string }) {
|
||||
yield coreExtensionData.reactElement(
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>{params.title}</h3>
|
||||
<p style={{ marginBottom: 0 }}>{params.description}</p>
|
||||
</div>,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page with cards that are individually toggled by permissions.
|
||||
//
|
||||
// The page itself is always present. What changes is which cards are
|
||||
// instantiated inside it — each card declares its own `enabled` predicate
|
||||
// and is only wired into the page if that predicate is satisfied at startup.
|
||||
//
|
||||
// To test: make sure you do NOT have the catalog.entity.create permission and
|
||||
// refresh the page — the "Restricted Card" below should disappear.
|
||||
const PermissionCardPage = PageBlueprint.makeWithOverrides({
|
||||
name: 'permissionCardExample',
|
||||
inputs: {
|
||||
cards: createExtensionInput([coreExtensionData.reactElement]),
|
||||
},
|
||||
factory(originalFactory, { inputs }) {
|
||||
return originalFactory({
|
||||
path: '/permission-card-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
const cards = inputs.cards.map(card =>
|
||||
card.get(coreExtensionData.reactElement),
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<h1>Permission-Gated Card Example</h1>
|
||||
<p>
|
||||
This page is always visible. The cards below are individually
|
||||
gated — each one declares its own{' '}
|
||||
<code>{'if: { permissions: { $contains: "..." } }'}</code>{' '}
|
||||
predicate. Cards whose predicate fails are never instantiated,
|
||||
so they simply won't appear here.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{cards.length > 0 ? (
|
||||
cards
|
||||
) : (
|
||||
<p>
|
||||
No cards are visible — you may lack the required
|
||||
permissions.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Always-visible card — no predicate, every user sees this.
|
||||
const PublicCard = PermissionExampleCardBlueprint.make({
|
||||
name: 'public',
|
||||
params: {
|
||||
title: 'Public Card',
|
||||
description: 'This card is visible to everyone regardless of permissions.',
|
||||
},
|
||||
});
|
||||
|
||||
// Permission-gated card — only instantiated when the user has
|
||||
// the catalog.entity.create permission.
|
||||
const RestrictedCard = PermissionExampleCardBlueprint.make({
|
||||
name: 'restricted',
|
||||
params: {
|
||||
title: 'Restricted Card',
|
||||
description:
|
||||
'This card is only visible to users who have the catalog.entity.create permission.',
|
||||
},
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
|
||||
// Feature flag-gated card — only instantiated when the user has
|
||||
// the experimental-card FF enabled.
|
||||
const FeatureFlagCard = PermissionExampleCardBlueprint.make({
|
||||
name: 'feature-flag',
|
||||
params: {
|
||||
title: 'Feature Flagged Card',
|
||||
description: 'Visible only with the experimental-card FF active.',
|
||||
},
|
||||
if: { featureFlags: { $contains: 'experimental-card' } },
|
||||
});
|
||||
|
||||
// Example: Page enabled only when the user is allowed to create catalog entities.
|
||||
//
|
||||
// The `if` predicate is evaluated once at app startup (after sign-in),
|
||||
// so this page simply won't exist in the router tree if the user lacks the
|
||||
// required permission.
|
||||
const PermissionGatedPage = PageBlueprint.make({
|
||||
name: 'permissionGatedExample',
|
||||
params: {
|
||||
path: '/permission-gated-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Permission Gated Page</h1>
|
||||
<p>
|
||||
This page is only present when the user has the{' '}
|
||||
<code>catalog.entity.create</code> permission.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
|
||||
export const pagesPlugin = createFrontendPlugin({
|
||||
pluginId: 'pages',
|
||||
// routes: {
|
||||
@@ -170,5 +486,23 @@ export const pagesPlugin = createFrontendPlugin({
|
||||
externalRoutes: {
|
||||
pageX: externalPageXRouteRef,
|
||||
},
|
||||
extensions: [IndexPage, Page1, ExternalPage],
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'advanced-features' },
|
||||
{ name: 'beta-access' },
|
||||
{ name: 'experimental-card' },
|
||||
],
|
||||
extensions: [
|
||||
IndexPage,
|
||||
Page1,
|
||||
ExternalPage,
|
||||
FeatureFlagPage,
|
||||
AllFlagsPage,
|
||||
AnyFlagPage,
|
||||
PermissionCardPage,
|
||||
PublicCard,
|
||||
RestrictedCard,
|
||||
PermissionGatedPage,
|
||||
FeatureFlagCard,
|
||||
],
|
||||
});
|
||||
|
||||
+2
-8
@@ -131,15 +131,9 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
|
||||
'/.backstage/actions/v1/actions/:actionId/invoke',
|
||||
async (req, res) => {
|
||||
const credentials = await this.httpAuth.credentials(req);
|
||||
if (this.auth.isPrincipal(credentials, 'user')) {
|
||||
if (!credentials.principal.actor) {
|
||||
throw new NotAllowedError(
|
||||
`Actions must be invoked by a service, not a user`,
|
||||
);
|
||||
}
|
||||
} else if (this.auth.isPrincipal(credentials, 'none')) {
|
||||
if (this.auth.isPrincipal(credentials, 'none')) {
|
||||
throw new NotAllowedError(
|
||||
`Actions must be invoked by a service, not an anonymous request`,
|
||||
`Actions must be invoked by an authenticated principal, not an anonymous request`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-7
@@ -443,7 +443,7 @@ describe('actionsRegistryServiceFactory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if the action is invoked by a user', async () => {
|
||||
it('should allow actions to be invoked by a user', async () => {
|
||||
const testServices = [
|
||||
actionsRegistryServiceFactory,
|
||||
httpRouterServiceFactory,
|
||||
@@ -464,12 +464,8 @@ describe('actionsRegistryServiceFactory', () => {
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toMatchObject({
|
||||
error: {
|
||||
message: 'Actions must be invoked by a service, not a user',
|
||||
},
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ output: { ok: true } });
|
||||
});
|
||||
|
||||
it('should validate the output of the action if provided', async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli-module-actions": "workspace:^",
|
||||
"@backstage/cli-module-auth": "workspace:^",
|
||||
"@backstage/cli-module-build": "workspace:^",
|
||||
"@backstage/cli-module-config": "workspace:^",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import actions from '@backstage/cli-module-actions';
|
||||
import auth from '@backstage/cli-module-auth';
|
||||
import build from '@backstage/cli-module-build';
|
||||
import config from '@backstage/cli-module-config';
|
||||
@@ -31,6 +32,7 @@ import translations from '@backstage/cli-module-translations';
|
||||
* @public
|
||||
*/
|
||||
export default [
|
||||
actions,
|
||||
auth,
|
||||
build,
|
||||
config,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Copyright 2025 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.
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const isLocal = require('node:fs').existsSync(
|
||||
path.resolve(__dirname, '../src'),
|
||||
);
|
||||
|
||||
if (isLocal) {
|
||||
require('@backstage/cli-node/config/nodeTransform.cjs');
|
||||
}
|
||||
|
||||
const { runCliModule } = require('@backstage/cli-node');
|
||||
const cliModule = require(isLocal ? '../src/index' : '..').default;
|
||||
const pkg = require('../package.json');
|
||||
runCliModule({ module: cliModule, name: pkg.name, version: pkg.version });
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-cli-module-actions
|
||||
title: '@backstage/cli-module-actions'
|
||||
description: CLI module for executing distributed actions
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-cli-module
|
||||
owner: tooling-maintainers
|
||||
@@ -0,0 +1,94 @@
|
||||
## CLI Report file for "@backstage/cli-module-actions"
|
||||
|
||||
> Do not edit this file. It is a report generated by `yarn build:api-reports`
|
||||
|
||||
### `backstage-cli-module-actions`
|
||||
|
||||
```
|
||||
Usage: @backstage/cli-module-actions [options] [command]
|
||||
|
||||
Options:
|
||||
-V, --version
|
||||
-h, --help
|
||||
|
||||
Commands:
|
||||
actions [command]
|
||||
help [command]
|
||||
```
|
||||
|
||||
### `backstage-cli-module-actions actions`
|
||||
|
||||
```
|
||||
Usage: @backstage/cli-module-actions actions [options] [command] [command]
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
|
||||
Commands:
|
||||
execute
|
||||
help [command]
|
||||
list
|
||||
sources [command]
|
||||
```
|
||||
|
||||
### `backstage-cli-module-actions actions execute`
|
||||
|
||||
```
|
||||
Usage: @backstage/cli-module-actions actions execute
|
||||
|
||||
Options:
|
||||
--instance <string>
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli-module-actions actions list`
|
||||
|
||||
```
|
||||
Usage: @backstage/cli-module-actions actions list
|
||||
|
||||
Options:
|
||||
--instance <string>
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli-module-actions actions sources`
|
||||
|
||||
```
|
||||
Usage: @backstage/cli-module-actions actions sources [options] [command] [command]
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
|
||||
Commands:
|
||||
add
|
||||
help [command]
|
||||
list
|
||||
remove
|
||||
```
|
||||
|
||||
### `backstage-cli-module-actions actions sources add`
|
||||
|
||||
```
|
||||
Usage: @backstage/cli-module-actions actions sources add
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli-module-actions actions sources list`
|
||||
|
||||
```
|
||||
Usage: @backstage/cli-module-actions actions sources list
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli-module-actions actions sources remove`
|
||||
|
||||
```
|
||||
Usage: @backstage/cli-module-actions actions sources remove
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@backstage/cli-module-actions",
|
||||
"version": "0.0.0",
|
||||
"description": "CLI module for executing distributed actions",
|
||||
"backstage": {
|
||||
"role": "cli-module"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/cli-module-actions"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"bin"
|
||||
],
|
||||
"bin": "bin/backstage-cli-module-actions",
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"clean": "backstage-cli package clean",
|
||||
"lint": "backstage-cli package lint",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli-module-auth": "workspace:^",
|
||||
"@backstage/cli-node": "workspace:^",
|
||||
"cleye": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
## API Report File for "@backstage/cli-module-actions"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { CliModule } from '@backstage/cli-node';
|
||||
|
||||
// @public (undocumented)
|
||||
const _default: CliModule;
|
||||
export default _default;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import { ActionsClient } from '../lib/ActionsClient';
|
||||
import { schemaToFlags } from '../lib/schemaToFlags';
|
||||
import { resolveAuth } from '../lib/resolveAuth';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
cli(
|
||||
{
|
||||
help: info,
|
||||
parameters: ['<action-id>'],
|
||||
flags: {
|
||||
instance: {
|
||||
type: String,
|
||||
description: 'Name of the instance to use',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceIdx = args.indexOf('--instance');
|
||||
const instanceFlag = instanceIdx !== -1 ? args[instanceIdx + 1] : undefined;
|
||||
|
||||
// Skip flag names, flag values (the argument after a known flag), and
|
||||
// the --instance value position so we only pick up positional arguments.
|
||||
const skipIndices = new Set<number>();
|
||||
if (instanceIdx !== -1) {
|
||||
skipIndices.add(instanceIdx);
|
||||
skipIndices.add(instanceIdx + 1);
|
||||
}
|
||||
|
||||
let actionId: string | undefined;
|
||||
let actionIdIdx = -1;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (!skipIndices.has(i) && !args[i].startsWith('-')) {
|
||||
actionId = args[i];
|
||||
actionIdIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!actionId) {
|
||||
process.stderr.write('Usage: actions execute <action-id> [flags]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { accessToken, instance } = await resolveAuth(instanceFlag);
|
||||
|
||||
const client = new ActionsClient(instance.baseUrl, accessToken);
|
||||
const actions = await client.listForPlugin(actionId);
|
||||
const action = actions.find(a => a.id === actionId);
|
||||
|
||||
if (!action) {
|
||||
throw new Error(
|
||||
`Action "${actionId}" not found. Run "actions list" to see available actions.`,
|
||||
);
|
||||
}
|
||||
|
||||
const schemaFlags = schemaToFlags(action.schema.input as any);
|
||||
|
||||
const flagArgs = args.filter((_, i) => i !== actionIdIdx);
|
||||
|
||||
const { flags } = cli(
|
||||
{
|
||||
help: info,
|
||||
flags: {
|
||||
instance: {
|
||||
type: String,
|
||||
description: 'Name of the instance to use',
|
||||
},
|
||||
...schemaFlags,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
flagArgs,
|
||||
);
|
||||
|
||||
const allFlags = flags as Record<string, unknown>;
|
||||
const input: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(allFlags)) {
|
||||
if (key !== 'instance' && value !== undefined) {
|
||||
input[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const output = await client.execute(actionId, input);
|
||||
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import { ActionsClient } from '../lib/ActionsClient';
|
||||
import { resolveAuth } from '../lib/resolveAuth';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { instance: instanceFlag },
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
flags: {
|
||||
instance: {
|
||||
type: String,
|
||||
description: 'Name of the instance to use',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const { accessToken, pluginSources, instance } = await resolveAuth(
|
||||
instanceFlag,
|
||||
);
|
||||
|
||||
if (!pluginSources.length) {
|
||||
process.stderr.write(
|
||||
'No plugin sources configured. Run "actions sources add <plugin-id>" to add one.\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new ActionsClient(instance.baseUrl, accessToken);
|
||||
const actions = await client.list(pluginSources);
|
||||
|
||||
if (!actions.length) {
|
||||
process.stderr.write('No actions found.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
const desc = action.description ? ` - ${action.description}` : '';
|
||||
process.stdout.write(`${action.id}${desc}\n`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
} from '@backstage/cli-module-auth';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const parsed = cli(
|
||||
{
|
||||
help: info,
|
||||
parameters: ['<plugin-id>'],
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const pluginId = parsed._[0];
|
||||
|
||||
const instance = await getSelectedInstance();
|
||||
const existing =
|
||||
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
|
||||
|
||||
if (existing.includes(pluginId)) {
|
||||
process.stderr.write(
|
||||
`Plugin source "${pluginId}" is already configured.\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateInstanceConfig(instance.name, 'pluginSources', [
|
||||
...existing,
|
||||
pluginId,
|
||||
]);
|
||||
|
||||
process.stdout.write(`Added plugin source "${pluginId}".\n`);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
} from '@backstage/cli-module-auth';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
cli({ help: info }, undefined, args);
|
||||
|
||||
const instance = await getSelectedInstance();
|
||||
const sources =
|
||||
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
|
||||
|
||||
if (!sources.length) {
|
||||
process.stderr.write('No plugin sources configured.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const source of sources) {
|
||||
process.stdout.write(`${source}\n`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
} from '@backstage/cli-module-auth';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const parsed = cli(
|
||||
{
|
||||
help: info,
|
||||
parameters: ['<plugin-id>'],
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const pluginId = parsed._[0];
|
||||
|
||||
const instance = await getSelectedInstance();
|
||||
const existing =
|
||||
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
|
||||
|
||||
if (!existing.includes(pluginId)) {
|
||||
process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateInstanceConfig(
|
||||
instance.name,
|
||||
'pluginSources',
|
||||
existing.filter(s => s !== pluginId),
|
||||
);
|
||||
|
||||
process.stdout.write(`Removed plugin source "${pluginId}".\n`);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2025 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 { createCliModule } from '@backstage/cli-node';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
export default createCliModule({
|
||||
packageJson,
|
||||
init: async reg => {
|
||||
reg.addCommand({
|
||||
path: ['actions', 'list'],
|
||||
description: 'List available actions from configured plugin sources',
|
||||
execute: { loader: () => import('./commands/list') },
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['actions', 'execute'],
|
||||
description: 'Execute an action',
|
||||
execute: { loader: () => import('./commands/execute') },
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['actions', 'sources', 'add'],
|
||||
description: 'Add a plugin source for action discovery',
|
||||
execute: { loader: () => import('./commands/sourcesAdd') },
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['actions', 'sources', 'list'],
|
||||
description: 'List configured plugin sources',
|
||||
execute: { loader: () => import('./commands/sourcesList') },
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['actions', 'sources', 'remove'],
|
||||
description: 'Remove a plugin source',
|
||||
execute: { loader: () => import('./commands/sourcesRemove') },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright 2025 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 { ActionsClient } from './ActionsClient';
|
||||
import { httpJson } from '@backstage/cli-module-auth';
|
||||
|
||||
jest.mock('@backstage/cli-module-auth', () => ({
|
||||
httpJson: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockHttpJson = httpJson as jest.MockedFunction<typeof httpJson>;
|
||||
|
||||
describe('ActionsClient', () => {
|
||||
const baseUrl = 'https://backstage.example.com';
|
||||
const accessToken = 'test-token';
|
||||
let client: ActionsClient;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
client = new ActionsClient(baseUrl, accessToken);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns empty array when no plugin sources provided', async () => {
|
||||
const result = await client.list([]);
|
||||
expect(result).toEqual([]);
|
||||
expect(mockHttpJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches actions from each plugin source', async () => {
|
||||
const catalogActions = [
|
||||
{
|
||||
id: 'catalog:refresh',
|
||||
name: 'refresh',
|
||||
schema: { input: {}, output: {} },
|
||||
},
|
||||
];
|
||||
const scaffolderActions = [
|
||||
{
|
||||
id: 'scaffolder:run',
|
||||
name: 'run',
|
||||
schema: { input: {}, output: {} },
|
||||
},
|
||||
];
|
||||
|
||||
mockHttpJson
|
||||
.mockResolvedValueOnce({ actions: catalogActions })
|
||||
.mockResolvedValueOnce({ actions: scaffolderActions });
|
||||
|
||||
const result = await client.list(['catalog', 'scaffolder']);
|
||||
|
||||
expect(mockHttpJson).toHaveBeenCalledTimes(2);
|
||||
expect(mockHttpJson).toHaveBeenCalledWith(
|
||||
'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
}),
|
||||
);
|
||||
expect(mockHttpJson).toHaveBeenCalledWith(
|
||||
'https://backstage.example.com/api/scaffolder/.backstage/actions/v1/actions',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual([...catalogActions, ...scaffolderActions]);
|
||||
});
|
||||
|
||||
it('propagates errors from httpJson', async () => {
|
||||
mockHttpJson.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(client.list(['catalog'])).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('posts to the correct invoke endpoint', async () => {
|
||||
mockHttpJson.mockResolvedValue({ output: { result: 'ok' } });
|
||||
|
||||
const output = await client.execute('catalog:refresh', {
|
||||
entityRef: 'component:default/foo',
|
||||
});
|
||||
|
||||
expect(mockHttpJson).toHaveBeenCalledWith(
|
||||
'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions/catalog%3Arefresh/invoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
body: { entityRef: 'component:default/foo' },
|
||||
}),
|
||||
);
|
||||
expect(output).toEqual({ result: 'ok' });
|
||||
});
|
||||
|
||||
it('sends empty object when no input provided', async () => {
|
||||
mockHttpJson.mockResolvedValue({ output: null });
|
||||
|
||||
await client.execute('catalog:refresh');
|
||||
|
||||
expect(mockHttpJson).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ body: {} }),
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts pluginId from actionId to build correct URL', async () => {
|
||||
mockHttpJson.mockResolvedValue({ output: {} });
|
||||
|
||||
await client.execute('my-plugin:some-action');
|
||||
|
||||
expect(mockHttpJson).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/my-plugin/'),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2025 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 { httpJson } from '@backstage/cli-module-auth';
|
||||
|
||||
export type ActionDef = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
schema: {
|
||||
input: object;
|
||||
output: object;
|
||||
};
|
||||
};
|
||||
|
||||
type ListActionsResponse = {
|
||||
actions: ActionDef[];
|
||||
};
|
||||
|
||||
type InvokeResponse = {
|
||||
output: unknown;
|
||||
};
|
||||
|
||||
function extractPluginId(actionId: string): string {
|
||||
const colonIndex = actionId.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
throw new Error(
|
||||
`Invalid action ID "${actionId}". Expected format "pluginId:actionName".`,
|
||||
);
|
||||
}
|
||||
return actionId.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
function pluginActionsUrl(baseUrl: string, pluginId: string): string {
|
||||
return new URL(
|
||||
`/api/${encodeURIComponent(pluginId)}/.backstage/actions/v1/actions`,
|
||||
baseUrl,
|
||||
).toString();
|
||||
}
|
||||
|
||||
export class ActionsClient {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly accessToken: string,
|
||||
) {}
|
||||
|
||||
async list(pluginSources: string[]): Promise<ActionDef[]> {
|
||||
const results: ActionDef[] = [];
|
||||
|
||||
for (const pluginId of pluginSources) {
|
||||
const url = pluginActionsUrl(this.baseUrl, pluginId);
|
||||
|
||||
const response = await httpJson<ListActionsResponse>(url, {
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
results.push(...response.actions);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async listForPlugin(actionId: string): Promise<ActionDef[]> {
|
||||
const pluginId = extractPluginId(actionId);
|
||||
return this.list([pluginId]);
|
||||
}
|
||||
|
||||
async execute(actionId: string, input?: unknown): Promise<unknown> {
|
||||
const pluginId = extractPluginId(actionId);
|
||||
const url = `${pluginActionsUrl(
|
||||
this.baseUrl,
|
||||
pluginId,
|
||||
)}/${encodeURIComponent(actionId)}/invoke`;
|
||||
|
||||
const response = await httpJson<InvokeResponse>(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
body: input ?? {},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
return response.output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2025 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 { resolveAuth } from './resolveAuth';
|
||||
import {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
accessTokenNeedsRefresh,
|
||||
refreshAccessToken,
|
||||
getSecretStore,
|
||||
type StoredInstance,
|
||||
} from '@backstage/cli-module-auth';
|
||||
|
||||
jest.mock('@backstage/cli-module-auth', () => ({
|
||||
getSelectedInstance: jest.fn(),
|
||||
getInstanceConfig: jest.fn(),
|
||||
accessTokenNeedsRefresh: jest.fn(),
|
||||
refreshAccessToken: jest.fn(),
|
||||
getSecretStore: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetSelectedInstance = getSelectedInstance as jest.MockedFunction<
|
||||
typeof getSelectedInstance
|
||||
>;
|
||||
const mockGetInstanceConfig = getInstanceConfig as jest.MockedFunction<
|
||||
typeof getInstanceConfig
|
||||
>;
|
||||
const mockAccessTokenNeedsRefresh =
|
||||
accessTokenNeedsRefresh as jest.MockedFunction<
|
||||
typeof accessTokenNeedsRefresh
|
||||
>;
|
||||
const mockRefreshAccessToken = refreshAccessToken as jest.MockedFunction<
|
||||
typeof refreshAccessToken
|
||||
>;
|
||||
const mockGetSecretStore = getSecretStore as jest.MockedFunction<
|
||||
typeof getSecretStore
|
||||
>;
|
||||
|
||||
describe('resolveAuth', () => {
|
||||
const mockInstance: StoredInstance = {
|
||||
name: 'production',
|
||||
baseUrl: 'https://backstage.example.com',
|
||||
clientId: 'my-client',
|
||||
issuedAt: Date.now(),
|
||||
accessTokenExpiresAt: Date.now() + 3600_000,
|
||||
};
|
||||
|
||||
const mockSecretStore = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetSelectedInstance.mockResolvedValue(mockInstance);
|
||||
mockAccessTokenNeedsRefresh.mockReturnValue(false);
|
||||
mockGetSecretStore.mockResolvedValue(mockSecretStore);
|
||||
mockSecretStore.get.mockResolvedValue('test-access-token');
|
||||
mockGetInstanceConfig.mockResolvedValue(['catalog', 'scaffolder']);
|
||||
});
|
||||
|
||||
it('resolves auth with the selected instance and stored token', async () => {
|
||||
const result = await resolveAuth();
|
||||
|
||||
expect(mockGetSelectedInstance).toHaveBeenCalledWith(undefined);
|
||||
expect(mockAccessTokenNeedsRefresh).toHaveBeenCalledWith(mockInstance);
|
||||
expect(mockRefreshAccessToken).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
instance: mockInstance,
|
||||
accessToken: 'test-access-token',
|
||||
pluginSources: ['catalog', 'scaffolder'],
|
||||
});
|
||||
});
|
||||
|
||||
it('passes instance name flag to getSelectedInstance', async () => {
|
||||
await resolveAuth('staging');
|
||||
|
||||
expect(mockGetSelectedInstance).toHaveBeenCalledWith('staging');
|
||||
});
|
||||
|
||||
it('refreshes the access token when it is about to expire', async () => {
|
||||
const refreshedInstance = {
|
||||
...mockInstance,
|
||||
accessTokenExpiresAt: Date.now() + 7200_000,
|
||||
};
|
||||
mockAccessTokenNeedsRefresh.mockReturnValue(true);
|
||||
mockRefreshAccessToken.mockResolvedValue(refreshedInstance);
|
||||
|
||||
const result = await resolveAuth();
|
||||
|
||||
expect(mockRefreshAccessToken).toHaveBeenCalledWith('production');
|
||||
expect(result.instance).toBe(refreshedInstance);
|
||||
});
|
||||
|
||||
it('throws when no access token is stored', async () => {
|
||||
mockSecretStore.get.mockResolvedValue(undefined);
|
||||
|
||||
await expect(resolveAuth()).rejects.toThrow(
|
||||
'No access token found. Run "auth login" to authenticate.',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty plugin sources when none are configured', async () => {
|
||||
mockGetInstanceConfig.mockResolvedValue(undefined);
|
||||
|
||||
const result = await resolveAuth();
|
||||
|
||||
expect(result.pluginSources).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2025 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 {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
accessTokenNeedsRefresh,
|
||||
refreshAccessToken,
|
||||
getSecretStore,
|
||||
type StoredInstance,
|
||||
} from '@backstage/cli-module-auth';
|
||||
|
||||
export async function resolveAuth(instanceFlag?: string): Promise<{
|
||||
instance: StoredInstance;
|
||||
accessToken: string;
|
||||
pluginSources: string[];
|
||||
}> {
|
||||
let instance = await getSelectedInstance(instanceFlag);
|
||||
|
||||
if (accessTokenNeedsRefresh(instance)) {
|
||||
instance = await refreshAccessToken(instance.name);
|
||||
}
|
||||
|
||||
const secretStore = await getSecretStore();
|
||||
const service = `backstage-cli:auth-instance:${instance.name}`;
|
||||
const accessToken = await secretStore.get(service, 'accessToken');
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token found. Run "auth login" to authenticate.');
|
||||
}
|
||||
|
||||
const pluginSources =
|
||||
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
|
||||
|
||||
return { instance, accessToken, pluginSources };
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2025 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 { schemaToFlags } from './schemaToFlags';
|
||||
|
||||
describe('schemaToFlags', () => {
|
||||
it('returns empty object when schema has no properties', () => {
|
||||
expect(schemaToFlags({})).toEqual({});
|
||||
expect(schemaToFlags({ properties: {} })).toEqual({});
|
||||
});
|
||||
|
||||
it('converts string properties to String flags', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
myProp: { type: 'string', description: 'A string prop' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(flags).toEqual({
|
||||
myProp: { type: String, description: 'A string prop' },
|
||||
});
|
||||
});
|
||||
|
||||
it('converts number and integer properties to Number flags', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
count: { type: 'integer' },
|
||||
amount: { type: 'number', description: 'An amount' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(flags.count).toEqual({ type: Number, description: undefined });
|
||||
expect(flags.amount).toEqual({ type: Number, description: 'An amount' });
|
||||
});
|
||||
|
||||
it('converts boolean properties to Boolean flags', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
verbose: { type: 'boolean', description: 'Enable verbose output' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(flags.verbose).toEqual({
|
||||
type: Boolean,
|
||||
description: 'Enable verbose output',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips non-primitive properties like object and array', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
metadata: { type: 'object' },
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(Object.keys(flags)).toEqual(['name']);
|
||||
});
|
||||
|
||||
it('skips properties with no type or composite types', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
noType: {},
|
||||
name: { type: 'string' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(Object.keys(flags)).toEqual(['name']);
|
||||
});
|
||||
|
||||
it('uses first type when type is an array', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
value: { type: ['string', 'null'] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(flags.value).toEqual({ type: String, description: undefined });
|
||||
});
|
||||
|
||||
it('appends enum values to description', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
description: 'Pick a color',
|
||||
enum: ['red', 'green', 'blue'],
|
||||
},
|
||||
bare: { type: 'string', enum: ['a', 'b'] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(flags.color.description).toBe('Pick a color [red, green, blue]');
|
||||
expect(flags.bare.description).toBe('[a, b]');
|
||||
});
|
||||
|
||||
it('marks required fields in description', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
name: { type: 'string', description: 'The name' },
|
||||
optional: { type: 'string', description: 'Optional field' },
|
||||
bare: { type: 'string' },
|
||||
},
|
||||
required: ['name', 'bare'],
|
||||
});
|
||||
|
||||
expect(flags.name.description).toBe('The name (required)');
|
||||
expect(flags.optional.description).toBe('Optional field');
|
||||
expect(flags.bare.description).toBe('(required)');
|
||||
});
|
||||
|
||||
it('applies default values from schema', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
count: { type: 'number', default: 10 },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(flags.count.default).toBe(10);
|
||||
expect(flags.name.default).toBeUndefined();
|
||||
});
|
||||
|
||||
it('combines enum and required in description', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
env: {
|
||||
type: 'string',
|
||||
description: 'Target env',
|
||||
enum: ['dev', 'prod'],
|
||||
},
|
||||
},
|
||||
required: ['env'],
|
||||
});
|
||||
|
||||
expect(flags.env.description).toBe('Target env [dev, prod] (required)');
|
||||
});
|
||||
|
||||
it('preserves camelCase property names as flag keys', () => {
|
||||
const flags = schemaToFlags({
|
||||
properties: {
|
||||
targetEntityRef: { type: 'string' },
|
||||
maxResults: { type: 'integer' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(Object.keys(flags)).toEqual(['targetEntityRef', 'maxResults']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2025 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.
|
||||
*/
|
||||
|
||||
type JsonSchemaProperty = {
|
||||
type?: string | string[];
|
||||
description?: string;
|
||||
enum?: unknown[];
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
type JsonSchemaObject = {
|
||||
properties?: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
type CleyeFlag = {
|
||||
type: StringConstructor | NumberConstructor | BooleanConstructor;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
export function schemaToFlags(
|
||||
schema: JsonSchemaObject,
|
||||
): Record<string, CleyeFlag> {
|
||||
const flags: Record<string, CleyeFlag> = {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
|
||||
if (!schema.properties) {
|
||||
return flags;
|
||||
}
|
||||
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
const rawType = Array.isArray(prop.type) ? prop.type[0] : prop.type;
|
||||
|
||||
let flagType: StringConstructor | NumberConstructor | BooleanConstructor;
|
||||
if (rawType === 'string') {
|
||||
flagType = String;
|
||||
} else if (rawType === 'number' || rawType === 'integer') {
|
||||
flagType = Number;
|
||||
} else if (rawType === 'boolean') {
|
||||
flagType = Boolean;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
let desc = prop.description ?? '';
|
||||
if (prop.enum?.length) {
|
||||
const values = prop.enum.map(v => String(v)).join(', ');
|
||||
desc = desc ? `${desc} [${values}]` : `[${values}]`;
|
||||
}
|
||||
if (required.has(key)) {
|
||||
desc = desc ? `${desc} (required)` : '(required)';
|
||||
}
|
||||
|
||||
const flag: CleyeFlag = { type: flagType, description: desc || undefined };
|
||||
if (prop.default !== undefined) {
|
||||
flag.default = prop.default;
|
||||
}
|
||||
|
||||
flags[key] = flag;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
@@ -35,7 +35,6 @@
|
||||
"@backstage/cli-node": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"cleye": "^2.3.0",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"glob": "^7.1.7",
|
||||
"inquirer": "^8.2.0",
|
||||
|
||||
@@ -5,9 +5,67 @@
|
||||
```ts
|
||||
import { CliModule } from '@backstage/cli-node';
|
||||
|
||||
// @public (undocumented)
|
||||
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
const _default: CliModule;
|
||||
export default _default;
|
||||
|
||||
// @public (undocumented)
|
||||
export function getInstanceConfig<T = unknown>(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
): Promise<T | undefined>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function getSecretStore(): Promise<SecretStore>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function getSelectedInstance(
|
||||
instanceName?: string,
|
||||
): Promise<StoredInstance>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type HttpInit = {
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
body?: any;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function httpJson<T>(url: string, init?: HttpInit): Promise<T>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function refreshAccessToken(
|
||||
instanceName: string,
|
||||
): Promise<StoredInstance>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type SecretStore = {
|
||||
get(service: string, account: string): Promise<string | undefined>;
|
||||
set(service: string, account: string, secret: string): Promise<void>;
|
||||
delete(service: string, account: string): Promise<void>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type StoredInstance = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
clientId: string;
|
||||
issuedAt: number;
|
||||
accessTokenExpiresAt: number;
|
||||
selected?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function updateInstanceConfig(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void>;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -52,3 +52,17 @@ export default createCliModule({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
type StoredInstance,
|
||||
} from './lib/storage';
|
||||
/** @public */
|
||||
export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth';
|
||||
/** @public */
|
||||
export { getSecretStore, type SecretStore } from './lib/secretStore';
|
||||
/** @public */
|
||||
export { httpJson, type HttpInit } from './lib/http';
|
||||
|
||||
@@ -31,10 +31,12 @@ const TokenResponseSchema = z.object({
|
||||
refresh_token: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean {
|
||||
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export async function refreshAccessToken(
|
||||
instanceName: string,
|
||||
): Promise<StoredInstance> {
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fetch from 'cross-fetch';
|
||||
import { httpJson } from './http';
|
||||
|
||||
jest.mock('cross-fetch');
|
||||
const mockFetch = jest.fn() as jest.MockedFunction<typeof global.fetch>;
|
||||
|
||||
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
describe('http', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,16 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fetch from 'cross-fetch';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
|
||||
type HttpInit = {
|
||||
/** @public */
|
||||
export type HttpInit = {
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
body?: any;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
|
||||
@@ -18,7 +18,8 @@ import fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
type SecretStore = {
|
||||
/** @public */
|
||||
export type SecretStore = {
|
||||
get(service: string, account: string): Promise<string | undefined>;
|
||||
set(service: string, account: string, secret: string): Promise<void>;
|
||||
delete(service: string, account: string): Promise<void>;
|
||||
@@ -89,6 +90,7 @@ class FileSecretStore implements SecretStore {
|
||||
|
||||
let singleton: SecretStore | undefined;
|
||||
|
||||
/** @public */
|
||||
export async function getSecretStore(): Promise<SecretStore> {
|
||||
if (!singleton) {
|
||||
const keytar = await loadKeytar();
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
getAllInstances,
|
||||
getSelectedInstance,
|
||||
getInstanceByName,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
upsertInstance,
|
||||
removeInstance,
|
||||
setSelectedInstance,
|
||||
@@ -357,6 +359,69 @@ describe('storage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstanceConfig', () => {
|
||||
it('should return undefined when no config set', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
|
||||
const result = await getInstanceConfig('production', 'someKey');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return config value for a key', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
await updateInstanceConfig('production', 'myKey', 'myValue');
|
||||
|
||||
const result = await getInstanceConfig('production', 'myKey');
|
||||
expect(result).toBe('myValue');
|
||||
});
|
||||
|
||||
it('should throw NotFoundError for unknown instance', async () => {
|
||||
await expect(getInstanceConfig('nonexistent', 'key')).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInstanceConfig', () => {
|
||||
it('should set a config value', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
await updateInstanceConfig('production', 'key1', 'value1');
|
||||
|
||||
const result = await getInstanceConfig('production', 'key1');
|
||||
expect(result).toBe('value1');
|
||||
});
|
||||
|
||||
it('should preserve existing config keys', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
await updateInstanceConfig('production', 'key1', 'value1');
|
||||
await updateInstanceConfig('production', 'key2', 'value2');
|
||||
|
||||
const result1 = await getInstanceConfig('production', 'key1');
|
||||
const result2 = await getInstanceConfig('production', 'key2');
|
||||
expect(result1).toBe('value1');
|
||||
expect(result2).toBe('value2');
|
||||
});
|
||||
|
||||
it('should throw NotFoundError for unknown instance', async () => {
|
||||
await expect(
|
||||
updateInstanceConfig('nonexistent', 'key', 'value'),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('should remove instance along with its config', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
await updateInstanceConfig('production', 'key1', 'value1');
|
||||
await removeInstance('production');
|
||||
|
||||
const { instances } = await getAllInstances();
|
||||
expect(instances.find(i => i.name === 'production')).toBeUndefined();
|
||||
|
||||
await upsertInstance(mockInstance1);
|
||||
const result = await getInstanceConfig('production', 'key1');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path resolution', () => {
|
||||
it('should use XDG_CONFIG_HOME when set', async () => {
|
||||
const customConfigHome = mockDir.resolve('custom-config');
|
||||
|
||||
@@ -36,9 +36,19 @@ const storedInstanceSchema = z.object({
|
||||
issuedAt: z.number().int().nonnegative(),
|
||||
accessTokenExpiresAt: z.number().int().nonnegative(),
|
||||
selected: z.boolean().optional(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type StoredInstance = z.infer<typeof storedInstanceSchema>;
|
||||
/** @public */
|
||||
export type StoredInstance = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
clientId: string;
|
||||
issuedAt: number;
|
||||
accessTokenExpiresAt: number;
|
||||
selected?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const authYamlSchema = z.object({
|
||||
instances: z.array(storedInstanceSchema).default([]),
|
||||
@@ -98,6 +108,7 @@ export async function getAllInstances(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export async function getSelectedInstance(
|
||||
instanceName?: string,
|
||||
): Promise<StoredInstance> {
|
||||
@@ -160,6 +171,35 @@ export async function setSelectedInstance(name: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export async function getInstanceConfig<T = unknown>(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
): Promise<T | undefined> {
|
||||
const instance = await getInstanceByName(instanceName);
|
||||
return instance.config?.[key] as T | undefined;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export async function updateInstanceConfig(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
return withMetadataLock(async () => {
|
||||
const data = await readAll();
|
||||
const idx = data.instances.findIndex(i => i.name === instanceName);
|
||||
if (idx === -1) {
|
||||
throw new NotFoundError(`Instance '${instanceName}' not found`);
|
||||
}
|
||||
data.instances[idx] = {
|
||||
...data.instances[idx],
|
||||
config: { ...data.instances[idx].config, [key]: value },
|
||||
};
|
||||
await writeAll(data);
|
||||
});
|
||||
}
|
||||
|
||||
export async function withMetadataLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const file = getMetadataFilePath();
|
||||
await fs.ensureDir(path.dirname(file));
|
||||
|
||||
@@ -23,6 +23,7 @@ export const defaultTemplates = [
|
||||
'@backstage/cli/templates/plugin-common-library',
|
||||
'@backstage/cli/templates/web-library',
|
||||
'@backstage/cli/templates/node-library',
|
||||
'@backstage/cli/templates/cli-module',
|
||||
'@backstage/cli/templates/catalog-provider-module',
|
||||
'@backstage/cli/templates/scaffolder-backend-module',
|
||||
];
|
||||
|
||||
@@ -160,6 +160,7 @@ export function getPromptsForRole(
|
||||
case 'web-library':
|
||||
case 'node-library':
|
||||
case 'common-library':
|
||||
case 'cli-module':
|
||||
return [namePrompt()];
|
||||
case 'plugin-web-library':
|
||||
case 'plugin-node-library':
|
||||
|
||||
@@ -37,6 +37,13 @@ describe.each([
|
||||
packagePath: 'packages/test',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'cli-module', name: 'test' },
|
||||
{
|
||||
packageName: '@internal/cli-module-test',
|
||||
packagePath: 'packages/cli-module-test',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'plugin-web-library', pluginId: 'test' },
|
||||
{
|
||||
|
||||
@@ -48,6 +48,8 @@ function getBaseNameForRole(
|
||||
case 'node-library':
|
||||
case 'common-library':
|
||||
return roleParams.name;
|
||||
case 'cli-module':
|
||||
return `cli-module-${roleParams.name}`;
|
||||
case 'plugin-web-library':
|
||||
return `${roleParams.pluginId}-react`;
|
||||
case 'plugin-node-library':
|
||||
|
||||
@@ -50,6 +50,7 @@ export const TEMPLATE_ROLES = [
|
||||
'web-library',
|
||||
'node-library',
|
||||
'common-library',
|
||||
'cli-module',
|
||||
'plugin-web-library',
|
||||
'plugin-node-library',
|
||||
'plugin-common-library',
|
||||
@@ -80,7 +81,7 @@ export type PortableTemplateParams = {
|
||||
|
||||
export type PortableTemplateInputRoleParams =
|
||||
| {
|
||||
role: 'web-library' | 'node-library' | 'common-library';
|
||||
role: 'web-library' | 'node-library' | 'common-library' | 'cli-module';
|
||||
name: string;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -12,6 +12,7 @@ Options:
|
||||
-h, --help
|
||||
|
||||
Commands:
|
||||
actions [command]
|
||||
auth [command]
|
||||
build-workspace
|
||||
config [command]
|
||||
@@ -31,6 +32,83 @@ Commands:
|
||||
versions:migrate
|
||||
```
|
||||
|
||||
### `backstage-cli actions`
|
||||
|
||||
```
|
||||
Usage: backstage-cli actions [options] [command] [command]
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
|
||||
Commands:
|
||||
execute
|
||||
help [command]
|
||||
list
|
||||
sources [command]
|
||||
```
|
||||
|
||||
### `backstage-cli actions execute`
|
||||
|
||||
```
|
||||
Usage: backstage-cli actions execute
|
||||
|
||||
Options:
|
||||
--instance <string>
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli actions list`
|
||||
|
||||
```
|
||||
Usage: backstage-cli actions list
|
||||
|
||||
Options:
|
||||
--instance <string>
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli actions sources`
|
||||
|
||||
```
|
||||
Usage: backstage-cli actions sources [options] [command] [command]
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
|
||||
Commands:
|
||||
add
|
||||
help [command]
|
||||
list
|
||||
remove
|
||||
```
|
||||
|
||||
### `backstage-cli actions sources add`
|
||||
|
||||
```
|
||||
Usage: backstage-cli actions sources add
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli actions sources list`
|
||||
|
||||
```
|
||||
Usage: backstage-cli actions sources list
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli actions sources remove`
|
||||
|
||||
```
|
||||
Usage: backstage-cli actions sources remove
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli auth`
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,5 @@
|
||||
# {{packageName}}
|
||||
|
||||
A CLI module that adds commands to the Backstage CLI.
|
||||
|
||||
_This package was created through the Backstage CLI_
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Copyright 2025 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.
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const isLocal = require('node:fs').existsSync(
|
||||
path.resolve(__dirname, '../src'),
|
||||
);
|
||||
|
||||
if (isLocal) {
|
||||
require('@backstage/cli-node/config/nodeTransform.cjs');
|
||||
}
|
||||
|
||||
const { runCliModule } = require('@backstage/cli-node');
|
||||
const cliModule = require(isLocal ? '../src/index' : '..').default;
|
||||
const pkg = require('../package.json');
|
||||
runCliModule({ module: cliModule, name: pkg.name, version: pkg.version });
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "{{packageName}}",
|
||||
"description": "CLI module for Backstage CLI",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "cli-module"
|
||||
},
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli-common": "{{versionQuery '@backstage/cli-common'}}",
|
||||
"@backstage/cli-node": "{{versionQuery '@backstage/cli-node'}}",
|
||||
"cleye": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin"
|
||||
],
|
||||
"bin": "bin/{{binName}}"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
name: cli-module
|
||||
role: cli-module
|
||||
description: A CLI module that adds commands to the Backstage CLI
|
||||
values:
|
||||
binName: 'backstage-cli-module-{{ name }}'
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const { flags } = cli(
|
||||
{
|
||||
help: info,
|
||||
booleanFlagNegation: true,
|
||||
flags: {
|
||||
name: {
|
||||
type: String,
|
||||
description: 'Your name',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const name = flags.name ?? 'World';
|
||||
console.log(`Hello, ${name}!`);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/***/
|
||||
/**
|
||||
* CLI module for the Backstage CLI.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { createCliModule } from '@backstage/cli-node';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
export default createCliModule({
|
||||
packageJson,
|
||||
init: async reg => {
|
||||
reg.addCommand({
|
||||
path: ['example'],
|
||||
description: 'An example command',
|
||||
execute: { loader: () => import('./commands/example') },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -33,6 +33,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-app-react": "workspace:^",
|
||||
"@backstage/plugin-catalog-react": "workspace:^",
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('convertLegacyPlugin', () => {
|
||||
"getExtension": [Function],
|
||||
"icon": undefined,
|
||||
"id": "test",
|
||||
"if": undefined,
|
||||
"info": [Function],
|
||||
"infoOptions": undefined,
|
||||
"pluginId": "test",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import { Progress } from './Progress';
|
||||
|
||||
@@ -23,4 +24,11 @@ describe('<Progress />', () => {
|
||||
const { queryByTestId } = await renderInTestApp(<Progress />);
|
||||
expect(queryByTestId('progress')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('provides an accessible name for the progress bar', async () => {
|
||||
await renderInTestApp(<Progress />);
|
||||
expect(
|
||||
await screen.findByRole('progressbar', { name: 'Loading' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useTheme } from '@material-ui/core/styles';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
|
||||
export function Progress(props: PropsWithChildren<LinearProgressProps>) {
|
||||
const { 'aria-label': ariaLabel, ...progressProps } = props;
|
||||
const theme = useTheme();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -34,7 +35,11 @@ export function Progress(props: PropsWithChildren<LinearProgressProps>) {
|
||||
}, [theme.transitions.duration.short]);
|
||||
|
||||
return isVisible ? (
|
||||
<LinearProgress {...props} data-testid="progress" />
|
||||
<LinearProgress
|
||||
{...progressProps}
|
||||
aria-label={ariaLabel ?? 'Loading'}
|
||||
data-testid="progress"
|
||||
/>
|
||||
) : (
|
||||
<Box display="none" data-testid="progress" />
|
||||
);
|
||||
|
||||
@@ -28,7 +28,6 @@ import { ComponentType } from 'react';
|
||||
import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import { configApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { createApiFactory } from '@backstage/frontend-plugin-api';
|
||||
import { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { DiscoveryApi } from '@backstage/frontend-plugin-api';
|
||||
import { discoveryApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { ErrorApi } from '@backstage/frontend-plugin-api';
|
||||
@@ -256,7 +255,8 @@ export { configApiRef };
|
||||
|
||||
export { createApiFactory };
|
||||
|
||||
export { createApiRef };
|
||||
// @public
|
||||
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
|
||||
|
||||
// @public
|
||||
export function createComponentExtension<
|
||||
|
||||
@@ -19,9 +19,15 @@ import { createApiRef } from './ApiRef';
|
||||
describe('ApiRef', () => {
|
||||
it('should be created', () => {
|
||||
const ref = createApiRef({ id: 'abc' });
|
||||
expect(ref.$$type).toBe('@backstage/ApiRef');
|
||||
expect(ref.id).toBe('abc');
|
||||
expect(String(ref)).toBe('apiRef{abc}');
|
||||
expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
|
||||
expect(ref.T).toBeNull();
|
||||
});
|
||||
|
||||
it('should not accept pluginId in the core createApiRef config', () => {
|
||||
// @ts-expect-error pluginId is not supported in core-plugin-api
|
||||
createApiRef<string>({ id: 'abc', pluginId: 'test' });
|
||||
});
|
||||
|
||||
it('should reject invalid ids', () => {
|
||||
|
||||
@@ -14,5 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
export type { ApiRefConfig } from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
createApiRef as createFrontendApiRef,
|
||||
type ApiRef,
|
||||
type ApiRefConfig,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
const createFrontendApiRefCompat = createFrontendApiRef as <T>(
|
||||
config: ApiRefConfig,
|
||||
) => ApiRef<T>;
|
||||
|
||||
/**
|
||||
* Creates a reference to an API.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> {
|
||||
return createFrontendApiRefCompat<T>(config);
|
||||
}
|
||||
|
||||
export type { ApiRefConfig };
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-defaults": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { ApiHolder as ApiHolder_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ApiHolder } from '@backstage/frontend-plugin-api';
|
||||
import { AppNode } from '@backstage/frontend-plugin-api';
|
||||
import { AppTree } from '@backstage/frontend-plugin-api';
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataContainer } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataValue } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionFactoryMiddleware as ExtensionFactoryMiddleware_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
@@ -128,6 +128,26 @@ export type AppErrorTypes = {
|
||||
existingPluginId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
apiRefId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
apiRefId: string;
|
||||
bootstrapNode: AppNode;
|
||||
pluginId: string;
|
||||
bootstrapPluginId: string;
|
||||
};
|
||||
};
|
||||
ROUTE_DUPLICATE: {
|
||||
context: {
|
||||
routeId: string;
|
||||
@@ -145,6 +165,12 @@ export type AppErrorTypes = {
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type BootstrapSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
tree: AppTree;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CreateAppRouteBinder = <
|
||||
TExternalRoutes extends {
|
||||
@@ -158,14 +184,12 @@ export type CreateAppRouteBinder = <
|
||||
>,
|
||||
) => void;
|
||||
|
||||
// @public
|
||||
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
// @public @deprecated
|
||||
export function createSpecializedApp(
|
||||
options?: CreateSpecializedAppOptions,
|
||||
): FinalizedSpecializedApp;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type CreateSpecializedAppOptions = {
|
||||
features?: FrontendFeature[];
|
||||
config?: ConfigApi;
|
||||
@@ -173,8 +197,8 @@ export type CreateSpecializedAppOptions = {
|
||||
advanced?: {
|
||||
apis?: ApiHolder;
|
||||
extensionFactoryMiddleware?:
|
||||
| ExtensionFactoryMiddleware
|
||||
| ExtensionFactoryMiddleware[];
|
||||
| ExtensionFactoryMiddleware_2
|
||||
| ExtensionFactoryMiddleware_2[];
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
};
|
||||
};
|
||||
@@ -186,11 +210,19 @@ export type ExtensionFactoryMiddleware = (
|
||||
}) => ExtensionDataContainer<ExtensionDataRef>,
|
||||
context: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder_2;
|
||||
apis: ApiHolder;
|
||||
config?: JsonObject;
|
||||
},
|
||||
) => Iterable<ExtensionDataValue<any, any>>;
|
||||
|
||||
// @public
|
||||
export type FinalizedSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
sessionState: SpecializedAppSessionState;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type FrontendPluginInfoResolver = (ctx: {
|
||||
packageJson(): Promise<JsonObject | undefined>;
|
||||
@@ -204,4 +236,35 @@ export type FrontendPluginInfoResolver = (ctx: {
|
||||
}) => Promise<{
|
||||
info: FrontendPluginInfo;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type PreparedSpecializedApp = {
|
||||
getBootstrapApp(): BootstrapSpecializedApp;
|
||||
onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
|
||||
finalize(): FinalizedSpecializedApp;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function prepareSpecializedApp(
|
||||
options?: PrepareSpecializedAppOptions,
|
||||
): PreparedSpecializedApp;
|
||||
|
||||
// @public
|
||||
export type PrepareSpecializedAppOptions = {
|
||||
features?: FrontendFeature[];
|
||||
config?: ConfigApi;
|
||||
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
|
||||
advanced?: {
|
||||
sessionState?: SpecializedAppSessionState;
|
||||
extensionFactoryMiddleware?:
|
||||
| ExtensionFactoryMiddleware_2
|
||||
| ExtensionFactoryMiddleware_2[];
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SpecializedAppSessionState = {
|
||||
$$type: '@backstage/SpecializedAppSessionState';
|
||||
};
|
||||
```
|
||||
|
||||
@@ -1782,4 +1782,96 @@ describe('instantiateAppNodeTree', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('if predicate', () => {
|
||||
function makeNodeWithEnabled(
|
||||
enabled: AppNodeSpec['if'],
|
||||
disabled = false,
|
||||
): AppNode {
|
||||
const ext = resolveExtensionDefinition(
|
||||
createExtension({
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: [testDataRef],
|
||||
factory: () => [testDataRef('value')],
|
||||
}),
|
||||
{ namespace: 'test-ext' },
|
||||
);
|
||||
return {
|
||||
spec: {
|
||||
id: ext.id,
|
||||
attachTo: ext.attachTo,
|
||||
disabled,
|
||||
if: enabled,
|
||||
extension: ext as Extension<unknown, unknown>,
|
||||
plugin: createFrontendPlugin({ pluginId: 'app' }),
|
||||
},
|
||||
edges: { attachments: new Map() },
|
||||
};
|
||||
}
|
||||
|
||||
it('should skip a node when the predicate is not satisfied', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
featureFlags: { $contains: 'the-flag' },
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: [],
|
||||
});
|
||||
expect(tree.root.instance).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should instantiate a node when the predicate is satisfied', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
featureFlags: { $contains: 'the-flag' },
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: ['the-flag'],
|
||||
});
|
||||
expect(tree.root.instance).toBeDefined();
|
||||
expect(tree.root.instance?.getData(testDataRef)).toBe('value');
|
||||
});
|
||||
|
||||
it('should support $all operator across multiple flags', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'flag-a' } },
|
||||
{ featureFlags: { $contains: 'flag-b' } },
|
||||
],
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
|
||||
// Only one flag active — should not instantiate
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: ['flag-a'],
|
||||
});
|
||||
expect(tree.root.instance).toBeUndefined();
|
||||
|
||||
// Both flags active — should instantiate
|
||||
const tree2 = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree2.root, testApis, collector, undefined, {
|
||||
featureFlags: ['flag-a', 'flag-b'],
|
||||
});
|
||||
expect(tree2.root.instance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should instantiate nodes without an enabled field regardless of predicateContext', () => {
|
||||
const node = makeNodeWithEnabled(undefined);
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: [],
|
||||
});
|
||||
expect(tree.root.instance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should instantiate nodes with enabled predicate when predicateContext is not provided', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
featureFlags: { $contains: 'the-flag' },
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
// No predicateContext passed — predicate evaluation is skipped
|
||||
instantiateAppNodeTree(tree.root, testApis, collector);
|
||||
expect(tree.root.instance).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
ExtensionDataContainer,
|
||||
ExtensionDataRef,
|
||||
ExtensionInput,
|
||||
ResolvedExtensionInputs,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension';
|
||||
import { ExtensionFactoryMiddleware } from '../wiring/types';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
|
||||
@@ -28,6 +29,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
|
||||
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
import { createExtensionDataContainer } from '@internal/frontend';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
import { evaluateFilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
const INSTANTIATION_FAILED = new Error('Instantiation failed');
|
||||
|
||||
@@ -63,6 +65,19 @@ type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
type InstantiateAppNodeSubtreeOptions = {
|
||||
rootNode: AppNode;
|
||||
apis: ApiHolder;
|
||||
collector: ErrorCollector;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
predicateContext?: Record<string, unknown>;
|
||||
reuseExistingInstances?: boolean;
|
||||
writeNodeInstances?: boolean;
|
||||
};
|
||||
|
||||
function resolveV1InputDataMap(
|
||||
dataMap: {
|
||||
[name in string]: ExtensionDataRef;
|
||||
@@ -336,12 +351,28 @@ export function createAppNodeInstance(options: {
|
||||
apis: ApiHolder;
|
||||
attachments: ReadonlyMap<string, AppNode[]>;
|
||||
collector: ErrorCollector;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
}): AppNodeInstance | undefined {
|
||||
const { node, apis, attachments } = options;
|
||||
const collector = options.collector.child({ node });
|
||||
const { id, extension, config } = node.spec;
|
||||
const extensionData = new Map<string, unknown>();
|
||||
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
|
||||
const scopedApis: ApiHolder =
|
||||
options.onMissingApi === undefined
|
||||
? apis
|
||||
: {
|
||||
get(apiRef) {
|
||||
const api = apis.get(apiRef);
|
||||
if (api === undefined) {
|
||||
options.onMissingApi?.({
|
||||
node,
|
||||
apiRefId: apiRef.id,
|
||||
});
|
||||
}
|
||||
return api;
|
||||
},
|
||||
};
|
||||
|
||||
let parsedConfig: { [x: string]: any };
|
||||
try {
|
||||
@@ -366,7 +397,7 @@ export function createAppNodeInstance(options: {
|
||||
if (internalExtension.version === 'v1') {
|
||||
const namedOutputs = internalExtension.factory({
|
||||
node,
|
||||
apis,
|
||||
apis: scopedApis,
|
||||
config: parsedConfig,
|
||||
inputs: resolveV1Inputs(internalExtension.inputs, attachments),
|
||||
});
|
||||
@@ -387,7 +418,7 @@ export function createAppNodeInstance(options: {
|
||||
} else if (internalExtension.version === 'v2') {
|
||||
const context = {
|
||||
node,
|
||||
apis,
|
||||
apis: scopedApis,
|
||||
config: parsedConfig,
|
||||
inputs: resolveV2Inputs(
|
||||
internalExtension.inputs,
|
||||
@@ -499,6 +530,87 @@ export function createAppNodeInstance(options: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting at the provided node, instantiate a subtree without necessarily
|
||||
* mutating the original app tree.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function instantiateAppNodeSubtree(
|
||||
options: InstantiateAppNodeSubtreeOptions,
|
||||
): AppNode | undefined {
|
||||
const instantiatedNodes = new WeakMap<AppNode, AppNode | null>();
|
||||
|
||||
function createInstance(node: AppNode): AppNode | undefined {
|
||||
if (instantiatedNodes.has(node)) {
|
||||
return instantiatedNodes.get(node) ?? undefined;
|
||||
}
|
||||
if (options.reuseExistingInstances !== false && node.instance) {
|
||||
instantiatedNodes.set(node, node);
|
||||
return node;
|
||||
}
|
||||
if (node.spec.disabled) {
|
||||
instantiatedNodes.set(node, null);
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
options.predicateContext !== undefined &&
|
||||
node.spec.if !== undefined &&
|
||||
!evaluateFilterPredicate(node.spec.if, options.predicateContext)
|
||||
) {
|
||||
instantiatedNodes.set(node, null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const instantiatedAttachments = new Map<string, AppNode[]>();
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
if (options.stopAtAttachment?.({ node, input })) {
|
||||
continue;
|
||||
}
|
||||
const instantiatedChildren = children.flatMap(child => {
|
||||
if (options.skipChild?.({ node, input, child })) {
|
||||
return [];
|
||||
}
|
||||
const childNode = createInstance(child);
|
||||
return childNode ? [childNode] : [];
|
||||
});
|
||||
if (instantiatedChildren.length > 0) {
|
||||
instantiatedAttachments.set(input, instantiatedChildren);
|
||||
}
|
||||
}
|
||||
|
||||
const instance = createAppNodeInstance({
|
||||
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
|
||||
node,
|
||||
apis: options.apis,
|
||||
attachments: instantiatedAttachments,
|
||||
collector: options.collector,
|
||||
onMissingApi: options.onMissingApi,
|
||||
});
|
||||
if (!instance) {
|
||||
instantiatedNodes.set(node, null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (options.writeNodeInstances === false) {
|
||||
const detachedNode: AppNode = {
|
||||
spec: node.spec,
|
||||
edges: node.edges,
|
||||
instance,
|
||||
};
|
||||
instantiatedNodes.set(node, detachedNode);
|
||||
return detachedNode;
|
||||
}
|
||||
|
||||
(node as Mutable<AppNode>).instance = instance;
|
||||
instantiatedNodes.set(node, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
return createInstance(options.rootNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting at the provided node, instantiate all reachable nodes in the tree that have not been disabled.
|
||||
* @internal
|
||||
@@ -508,40 +620,45 @@ export function instantiateAppNodeTree(
|
||||
apis: ApiHolder,
|
||||
collector: ErrorCollector,
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
|
||||
): boolean {
|
||||
function createInstance(node: AppNode): AppNodeInstance | undefined {
|
||||
if (node.instance) {
|
||||
return node.instance;
|
||||
}
|
||||
if (node.spec.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const instantiatedAttachments = new Map<string, AppNode[]>();
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
const instantiatedChildren = children.flatMap(child => {
|
||||
const childInstance = createInstance(child);
|
||||
if (!childInstance) {
|
||||
return [];
|
||||
}
|
||||
return [child];
|
||||
});
|
||||
if (instantiatedChildren.length > 0) {
|
||||
instantiatedAttachments.set(input, instantiatedChildren);
|
||||
optionsOrPredicateContext?:
|
||||
| {
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
skipChild?(ctx: {
|
||||
node: AppNode;
|
||||
input: string;
|
||||
child: AppNode;
|
||||
}): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
predicateContext?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
| Record<string, unknown>,
|
||||
): boolean {
|
||||
const options: {
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
predicateContext?: Record<string, unknown>;
|
||||
} =
|
||||
optionsOrPredicateContext &&
|
||||
('stopAtAttachment' in optionsOrPredicateContext ||
|
||||
'skipChild' in optionsOrPredicateContext ||
|
||||
'onMissingApi' in optionsOrPredicateContext ||
|
||||
'predicateContext' in optionsOrPredicateContext)
|
||||
? optionsOrPredicateContext
|
||||
: {
|
||||
predicateContext: optionsOrPredicateContext,
|
||||
};
|
||||
|
||||
(node as Mutable<AppNode>).instance = createAppNodeInstance({
|
||||
extensionFactoryMiddleware,
|
||||
node,
|
||||
return (
|
||||
instantiateAppNodeSubtree({
|
||||
rootNode,
|
||||
apis,
|
||||
attachments: instantiatedAttachments,
|
||||
collector,
|
||||
});
|
||||
|
||||
return node.instance;
|
||||
}
|
||||
|
||||
return createInstance(rootNode) !== undefined;
|
||||
extensionFactoryMiddleware,
|
||||
stopAtAttachment: options.stopAtAttachment,
|
||||
skipChild: options.skipChild,
|
||||
onMissingApi: options.onMissingApi,
|
||||
predicateContext: options.predicateContext,
|
||||
}) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createExtension,
|
||||
createExtensionDataRef,
|
||||
createFrontendModule,
|
||||
createFrontendPlugin,
|
||||
Extension,
|
||||
@@ -506,4 +508,204 @@ describe('resolveAppNodeSpecs', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry if predicate through to AppNodeSpec', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const ifPredicate = { featureFlags: { $contains: 'my-flag' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
if: ifPredicate,
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('value')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const specs = resolveAppNodeSpecs({
|
||||
features: [plugin],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0].if).toEqual(ifPredicate);
|
||||
});
|
||||
|
||||
it('should apply plugin if predicates to all plugin extensions', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
if: pluginIf,
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'one',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('one')],
|
||||
}),
|
||||
createExtension({
|
||||
name: 'two',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('two')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const specs = resolveAppNodeSpecs({
|
||||
features: [plugin],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
|
||||
expect(specs).toHaveLength(2);
|
||||
expect(specs[0].if).toEqual(pluginIf);
|
||||
expect(specs[1].if).toEqual(pluginIf);
|
||||
});
|
||||
|
||||
it('should allow plugin overrides to replace or remove plugin if predicates', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
|
||||
const overrideIf = { permissions: { $contains: 'override.permission' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
if: pluginIf,
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'one',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('one')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const overriddenSpecs = resolveAppNodeSpecs({
|
||||
features: [plugin.withOverrides({ if: overrideIf })],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
const clearedSpecs = resolveAppNodeSpecs({
|
||||
features: [plugin.withOverrides({ if: undefined })],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
|
||||
expect(overriddenSpecs).toHaveLength(1);
|
||||
expect(overriddenSpecs[0].if).toEqual(overrideIf);
|
||||
expect(clearedSpecs).toHaveLength(1);
|
||||
expect(clearedSpecs[0].if).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should merge plugin and module if predicates with extension predicates', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
|
||||
const moduleIf = { permissions: { $contains: 'module.permission' } };
|
||||
const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
|
||||
const moduleExtensionIf = { featureFlags: { $contains: 'module-flag' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
if: pluginIf,
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'plugin-extension',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
if: extensionIf,
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('plugin')],
|
||||
}),
|
||||
createExtension({
|
||||
name: 'module-extension',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('base')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const module = createFrontendModule({
|
||||
pluginId: 'test-plugin',
|
||||
if: moduleIf,
|
||||
extensions: [
|
||||
plugin.getExtension('test-plugin/module-extension').override({
|
||||
if: moduleExtensionIf,
|
||||
factory: () => [dataRef('module')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const specs = resolveAppNodeSpecs({
|
||||
features: [plugin, module],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
|
||||
expect(specs).toHaveLength(2);
|
||||
expect(specs[0].id).toBe('test-plugin/plugin-extension');
|
||||
expect(specs[0].if).toEqual({ $all: [pluginIf, extensionIf] });
|
||||
expect(specs[1].id).toBe('test-plugin/module-extension');
|
||||
expect(specs[1].if).toEqual({ $all: [moduleIf, moduleExtensionIf] });
|
||||
});
|
||||
|
||||
it('should allow module extension overrides to replace or remove extension if predicates', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
|
||||
const overrideIf = { permissions: { $contains: 'override.permission' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'extension',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
if: extensionIf,
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('base')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const overriddenSpecs = resolveAppNodeSpecs({
|
||||
features: [
|
||||
plugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
plugin.getExtension('test-plugin/extension').override({
|
||||
if: overrideIf,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
const clearedSpecs = resolveAppNodeSpecs({
|
||||
features: [
|
||||
plugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
plugin.getExtension('test-plugin/extension').override({
|
||||
if: undefined,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
|
||||
expect(overriddenSpecs).toHaveLength(1);
|
||||
expect(overriddenSpecs[0].if).toEqual(overrideIf);
|
||||
expect(clearedSpecs).toHaveLength(1);
|
||||
expect(clearedSpecs[0].if).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
FrontendFeature,
|
||||
FrontendPlugin,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { ExtensionParameters } from './readAppExtensionsConfig';
|
||||
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueFrontendPlugin } from '@internal/frontend';
|
||||
@@ -40,6 +41,29 @@ function normalizePlugin(plugin: FrontendPlugin): FrontendPlugin {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
function combinePredicates(
|
||||
left: FilterPredicate | undefined,
|
||||
right: FilterPredicate | undefined,
|
||||
) {
|
||||
if (!left) {
|
||||
return right;
|
||||
}
|
||||
if (!right) {
|
||||
return left;
|
||||
}
|
||||
|
||||
return { $all: [left, right] };
|
||||
}
|
||||
|
||||
function getExtensionPredicate(options: {
|
||||
internalExtension: ReturnType<typeof toInternalExtension>;
|
||||
}) {
|
||||
if (options.internalExtension.version === 'v2') {
|
||||
return options.internalExtension.if;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function resolveAppNodeSpecs(options: {
|
||||
features?: FrontendFeature[];
|
||||
@@ -79,26 +103,50 @@ export function resolveAppNodeSpecs(options: {
|
||||
};
|
||||
|
||||
const pluginExtensions = plugins.flatMap(plugin => {
|
||||
return OpaqueFrontendPlugin.toInternal(plugin)
|
||||
.extensions.map(extension => ({
|
||||
...extension,
|
||||
plugin,
|
||||
}))
|
||||
const internalPlugin = OpaqueFrontendPlugin.toInternal(plugin);
|
||||
return internalPlugin.extensions
|
||||
.map(extension => {
|
||||
const internalExtension = toInternalExtension(extension);
|
||||
return {
|
||||
...internalExtension,
|
||||
plugin,
|
||||
if: combinePredicates(
|
||||
internalPlugin.if,
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.if
|
||||
: undefined,
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter(filterForbidden);
|
||||
});
|
||||
const moduleExtensions = modules.flatMap(mod =>
|
||||
toInternalFrontendModule(mod)
|
||||
.extensions.flatMap(extension => {
|
||||
const moduleExtensions = modules.flatMap(mod => {
|
||||
const internalModule = toInternalFrontendModule(mod);
|
||||
return internalModule.extensions
|
||||
.flatMap(extension => {
|
||||
const internalExtension = toInternalExtension(extension);
|
||||
|
||||
// Modules for plugins that are not installed are ignored
|
||||
const plugin = plugins.find(p => p.pluginId === mod.pluginId);
|
||||
if (!plugin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ ...extension, plugin }];
|
||||
return [
|
||||
{
|
||||
...internalExtension,
|
||||
plugin,
|
||||
if: combinePredicates(
|
||||
internalModule.if,
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.if
|
||||
: undefined,
|
||||
),
|
||||
},
|
||||
];
|
||||
})
|
||||
.filter(filterForbidden),
|
||||
);
|
||||
.filter(filterForbidden);
|
||||
});
|
||||
|
||||
const appPlugin =
|
||||
plugins.find(plugin => plugin.pluginId === 'app') ??
|
||||
@@ -116,6 +164,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
source: plugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
if: getExtensionPredicate({ internalExtension }),
|
||||
config: undefined as unknown,
|
||||
},
|
||||
};
|
||||
@@ -129,6 +178,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
plugin: appPlugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
if: getExtensionPredicate({ internalExtension }),
|
||||
config: undefined as unknown,
|
||||
},
|
||||
};
|
||||
@@ -148,6 +198,9 @@ export function resolveAppNodeSpecs(options: {
|
||||
configuredExtensions[index].extension = internalExtension;
|
||||
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
|
||||
configuredExtensions[index].params.disabled = internalExtension.disabled;
|
||||
configuredExtensions[index].params.if = getExtensionPredicate({
|
||||
internalExtension,
|
||||
});
|
||||
} else {
|
||||
// Add the extension as a new one when not overriding an existing one
|
||||
configuredExtensions.push({
|
||||
@@ -157,6 +210,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
source: extension.plugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
if: getExtensionPredicate({ internalExtension }),
|
||||
config: undefined,
|
||||
},
|
||||
});
|
||||
@@ -235,6 +289,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
attachTo: param.params.attachTo,
|
||||
extension: param.extension,
|
||||
disabled: param.params.disabled,
|
||||
if: param.params.if,
|
||||
plugin: param.params.plugin,
|
||||
source: param.params.source,
|
||||
config: param.params.config,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
type AnyApiFactory,
|
||||
createApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
FrontendApiRegistry,
|
||||
FrontendApiResolver,
|
||||
} from './FrontendApiRegistry';
|
||||
|
||||
describe('FrontendApiResolver', () => {
|
||||
it('should cache falsy API values', () => {
|
||||
const falseApiRef = createApiRef<boolean>({ id: 'test.false' });
|
||||
const falseFactoryFn = jest.fn(() => false);
|
||||
const registry = new FrontendApiRegistry();
|
||||
|
||||
registry.register({
|
||||
api: falseApiRef,
|
||||
deps: {},
|
||||
factory: falseFactoryFn,
|
||||
} as AnyApiFactory);
|
||||
|
||||
const resolver = new FrontendApiResolver({ primaryRegistry: registry });
|
||||
|
||||
expect(resolver.get(falseApiRef)).toBe(false);
|
||||
expect(resolver.get(falseApiRef)).toBe(false);
|
||||
expect(falseFactoryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should resolve falsy dependencies', () => {
|
||||
const falseApiRef = createApiRef<boolean>({ id: 'test.false' });
|
||||
const dependentApiRef = createApiRef<string>({ id: 'test.dependent' });
|
||||
const falseFactoryFn = jest.fn(() => false);
|
||||
const dependentFactoryFn = jest.fn((deps: { falseDependency: boolean }) =>
|
||||
deps.falseDependency === false ? 'resolved' : 'unexpected',
|
||||
);
|
||||
const registry = new FrontendApiRegistry();
|
||||
|
||||
registry.register({
|
||||
api: falseApiRef,
|
||||
deps: {},
|
||||
factory: falseFactoryFn,
|
||||
} as AnyApiFactory);
|
||||
registry.register({
|
||||
api: dependentApiRef,
|
||||
deps: { falseDependency: falseApiRef },
|
||||
factory: dependentFactoryFn,
|
||||
} as AnyApiFactory);
|
||||
|
||||
const resolver = new FrontendApiResolver({ primaryRegistry: registry });
|
||||
|
||||
expect(resolver.get(dependentApiRef)).toBe('resolved');
|
||||
expect(dependentFactoryFn).toHaveBeenCalledWith({
|
||||
falseDependency: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
AnyApiFactory,
|
||||
AnyApiRef,
|
||||
ApiFactory,
|
||||
ApiHolder,
|
||||
ApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
export class FrontendApiRegistry {
|
||||
private readonly factories = new Map<string, AnyApiFactory>();
|
||||
|
||||
register(factory: AnyApiFactory) {
|
||||
if (this.factories.has(factory.api.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.factories.set(factory.api.id, factory);
|
||||
return true;
|
||||
}
|
||||
|
||||
registerAll(factories: AnyApiFactory[]) {
|
||||
for (const factory of factories) {
|
||||
this.register(factory);
|
||||
}
|
||||
}
|
||||
|
||||
set(factory: AnyApiFactory) {
|
||||
this.factories.set(factory.api.id, factory);
|
||||
}
|
||||
|
||||
setAll(factories: Iterable<AnyApiFactory>) {
|
||||
for (const factory of factories) {
|
||||
this.set(factory);
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(
|
||||
api: ApiRef<T>,
|
||||
): ApiFactory<T, T, { [name: string]: unknown }> | undefined {
|
||||
const factory = this.factories.get(api.id);
|
||||
if (!factory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return factory as ApiFactory<T, T, { [name: string]: unknown }>;
|
||||
}
|
||||
|
||||
getAllApis() {
|
||||
const refs = new Set<AnyApiRef>();
|
||||
for (const factory of this.factories.values()) {
|
||||
refs.add(factory.api);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
}
|
||||
|
||||
export class FrontendApiResolver implements ApiHolder {
|
||||
private readonly apis = new Map<string, unknown>();
|
||||
private readonly primaryRegistry?: FrontendApiRegistry;
|
||||
private readonly secondaryRegistry?: FrontendApiRegistry;
|
||||
private readonly fallbackApis?: ApiHolder;
|
||||
|
||||
constructor(options: {
|
||||
primaryRegistry?: FrontendApiRegistry;
|
||||
secondaryRegistry?: FrontendApiRegistry;
|
||||
fallbackApis?: ApiHolder;
|
||||
}) {
|
||||
this.primaryRegistry = options.primaryRegistry;
|
||||
this.secondaryRegistry = options.secondaryRegistry;
|
||||
this.fallbackApis = options.fallbackApis;
|
||||
}
|
||||
|
||||
get<T>(ref: ApiRef<T>): T | undefined {
|
||||
return this.load(ref);
|
||||
}
|
||||
|
||||
isMaterialized(apiRefId: string) {
|
||||
return this.apis.has(apiRefId);
|
||||
}
|
||||
|
||||
invalidate(apiRefIds?: Iterable<string>) {
|
||||
if (apiRefIds === undefined) {
|
||||
this.apis.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const apiRefId of apiRefIds) {
|
||||
this.apis.delete(apiRefId);
|
||||
}
|
||||
}
|
||||
|
||||
private load<T>(ref: ApiRef<T>, loading: AnyApiRef[] = []): T | undefined {
|
||||
const existing = this.apis.get(ref.id);
|
||||
if (this.apis.has(ref.id)) {
|
||||
return existing as T;
|
||||
}
|
||||
|
||||
const factory =
|
||||
this.primaryRegistry?.get(ref) ?? this.secondaryRegistry?.get(ref);
|
||||
if (!factory) {
|
||||
return this.fallbackApis?.get(ref);
|
||||
}
|
||||
|
||||
if (loading.includes(factory.api)) {
|
||||
throw new Error(`Circular dependency of api factory for ${factory.api}`);
|
||||
}
|
||||
|
||||
const deps = {} as { [name: string]: unknown };
|
||||
for (const [key, depRef] of Object.entries(factory.deps)) {
|
||||
const dep = this.load(depRef, [...loading, factory.api]);
|
||||
if (dep === undefined) {
|
||||
throw new Error(
|
||||
`No API factory available for dependency ${depRef} of dependent ${factory.api}`,
|
||||
);
|
||||
}
|
||||
deps[key] = dep;
|
||||
}
|
||||
|
||||
const api = factory.factory(deps);
|
||||
this.apis.set(ref.id, api);
|
||||
return api as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiBlueprint,
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
FrontendFeature,
|
||||
featureFlagsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueFrontendPlugin } from '@internal/frontend';
|
||||
import { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
isInternalFrontendModule,
|
||||
toInternalFrontendModule,
|
||||
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
|
||||
import { ErrorCollector } from './createErrorCollector';
|
||||
import {
|
||||
FrontendApiRegistry,
|
||||
FrontendApiResolver,
|
||||
} from './FrontendApiRegistry';
|
||||
import { type ExtensionPredicateContext } from './predicates';
|
||||
|
||||
export type ApiFactoryEntry = {
|
||||
node: AppNode;
|
||||
pluginId: string;
|
||||
factory: AnyApiFactory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers feature flag declarations on an already prepared API holder.
|
||||
*
|
||||
* This is primarily used when bootstrap reuses APIs from a provided session
|
||||
* state rather than building a fresh registry from bootstrap-visible factories.
|
||||
*/
|
||||
export function registerFeatureFlagDeclarationsInHolder(
|
||||
apis: ApiHolder,
|
||||
features: FrontendFeature[],
|
||||
) {
|
||||
const featureFlagApi = apis.get(featureFlagsApiRef);
|
||||
if (featureFlagApi) {
|
||||
registerFeatureFlagDeclarations(featureFlagApi, features);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates the feature flags API factory so plugin and module declarations are
|
||||
* registered whenever that API is instantiated.
|
||||
*/
|
||||
export function wrapFeatureFlagApiFactory(
|
||||
factory: AnyApiFactory,
|
||||
features: FrontendFeature[],
|
||||
) {
|
||||
if (factory.api.id !== featureFlagsApiRef.id) {
|
||||
return factory;
|
||||
}
|
||||
|
||||
return {
|
||||
...factory,
|
||||
factory(deps) {
|
||||
const featureFlagApi = factory.factory(
|
||||
deps,
|
||||
) as typeof featureFlagsApiRef.T;
|
||||
registerFeatureFlagDeclarations(featureFlagApi, features);
|
||||
return featureFlagApi;
|
||||
},
|
||||
} as AnyApiFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles deferred API factories into the finalized API registry.
|
||||
*
|
||||
* It preserves bootstrap-frozen APIs, allows safe deferred additions, and
|
||||
* reports cases where bootstrap-visible extensions relied on APIs that only
|
||||
* became available during finalization.
|
||||
*/
|
||||
export function syncFinalApiFactories(options: {
|
||||
deferredApiNodes: Iterable<AppNode>;
|
||||
appApiRegistry: FrontendApiRegistry;
|
||||
apiResolver: FrontendApiResolver;
|
||||
collector: ErrorCollector;
|
||||
features: FrontendFeature[];
|
||||
bootstrapApiFactoryEntries: ReadonlyMap<string, ApiFactoryEntry>;
|
||||
bootstrapMissingApiAccesses: Map<string, { node: AppNode; apiRefId: string }>;
|
||||
predicateContext: ExtensionPredicateContext;
|
||||
}) {
|
||||
const finalApiEntries = collectApiFactoryEntries({
|
||||
apiNodes: options.deferredApiNodes,
|
||||
collector: options.collector,
|
||||
predicateContext: options.predicateContext,
|
||||
entries: new Map(options.bootstrapApiFactoryEntries),
|
||||
});
|
||||
// Only newly introduced or still-safe overrides are registered here. Any
|
||||
// bootstrap-materialized API remains frozen for the lifetime of the app.
|
||||
const changedEntries = Array.from(finalApiEntries.values()).filter(entry => {
|
||||
const bootstrapEntry = options.bootstrapApiFactoryEntries.get(
|
||||
entry.factory.api.id,
|
||||
);
|
||||
if (!bootstrapEntry) {
|
||||
return true;
|
||||
}
|
||||
if (bootstrapEntry.factory === entry.factory) {
|
||||
return false;
|
||||
}
|
||||
if (options.apiResolver.isMaterialized(entry.factory.api.id)) {
|
||||
options.collector.report({
|
||||
code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED',
|
||||
message:
|
||||
`Extension '${entry.node.spec.id}' tried to override API ` +
|
||||
`'${entry.factory.api.id}' after it had already been materialized during bootstrap. ` +
|
||||
'The bootstrap implementation was kept and the deferred override was ignored.',
|
||||
context: {
|
||||
node: entry.node,
|
||||
apiRefId: entry.factory.api.id,
|
||||
bootstrapNode: bootstrapEntry.node,
|
||||
pluginId: entry.pluginId,
|
||||
bootstrapPluginId: bootstrapEntry.pluginId,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const changedFactories = changedEntries.map(entry =>
|
||||
wrapFeatureFlagApiFactory(entry.factory, options.features),
|
||||
);
|
||||
options.appApiRegistry.setAll(changedFactories);
|
||||
options.apiResolver.invalidate(
|
||||
changedFactories.map(factory => factory.api.id),
|
||||
);
|
||||
for (const bootstrapAccess of options.bootstrapMissingApiAccesses.values()) {
|
||||
if (
|
||||
options.bootstrapApiFactoryEntries.has(bootstrapAccess.apiRefId) ||
|
||||
!finalApiEntries.has(bootstrapAccess.apiRefId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.collector.report({
|
||||
code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
|
||||
message:
|
||||
`Extension '${bootstrapAccess.node.spec.id}' tried to access API ` +
|
||||
`'${bootstrapAccess.apiRefId}' during bootstrap before it was available. ` +
|
||||
'That API became available during finalization, so bootstrap-visible extensions must not depend on deferred APIs.',
|
||||
context: {
|
||||
node: bootstrapAccess.node,
|
||||
apiRefId: bootstrapAccess.apiRefId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_API_HOLDER: ApiHolder = {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
function registerFeatureFlagDeclarations(
|
||||
featureFlagApi: typeof featureFlagsApiRef.T,
|
||||
features: FrontendFeature[],
|
||||
) {
|
||||
for (const feature of features) {
|
||||
if (OpaqueFrontendPlugin.isType(feature)) {
|
||||
OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (isInternalFrontendModule(feature)) {
|
||||
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.pluginId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates API extension subtrees in isolation and extracts the factories
|
||||
* they provide without mutating the live app tree.
|
||||
*
|
||||
* The collected entries are later used both for bootstrap registration and for
|
||||
* the finalization-time reconciliation of deferred API roots.
|
||||
*/
|
||||
export function collectApiFactoryEntries(options: {
|
||||
apiNodes: Iterable<AppNode>;
|
||||
collector: ErrorCollector;
|
||||
predicateContext?: ExtensionPredicateContext;
|
||||
entries?: Map<string, ApiFactoryEntry>;
|
||||
}): Map<string, ApiFactoryEntry> {
|
||||
const factoriesById = options.entries ?? new Map<string, ApiFactoryEntry>();
|
||||
for (const apiNode of options.apiNodes) {
|
||||
// API extensions are instantiated in isolation so we can inspect the
|
||||
// produced factories without mutating the live app tree.
|
||||
const detachedApiNode = instantiateAppNodeSubtree({
|
||||
rootNode: apiNode,
|
||||
apis: EMPTY_API_HOLDER,
|
||||
collector: options.collector,
|
||||
predicateContext: options.predicateContext,
|
||||
writeNodeInstances: false,
|
||||
reuseExistingInstances: false,
|
||||
});
|
||||
if (!detachedApiNode) {
|
||||
continue;
|
||||
}
|
||||
const apiFactory = detachedApiNode.instance?.getData(
|
||||
ApiBlueprint.dataRefs.factory,
|
||||
);
|
||||
if (apiFactory) {
|
||||
const apiRefId = apiFactory.api.id;
|
||||
const ownerId = getApiOwnerId(apiRefId);
|
||||
const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
|
||||
const existingFactory = factoriesById.get(apiRefId);
|
||||
|
||||
// This allows modules to override factories provided by the plugin, but
|
||||
// it rejects API overrides from other plugins. In the event of a
|
||||
// conflict, the owning plugin is attempted to be inferred from the API
|
||||
// reference ID.
|
||||
if (existingFactory && existingFactory.pluginId !== pluginId) {
|
||||
const shouldReplace =
|
||||
ownerId === pluginId && existingFactory.pluginId !== ownerId;
|
||||
const acceptedPluginId = shouldReplace
|
||||
? pluginId
|
||||
: existingFactory.pluginId;
|
||||
const rejectedPluginId = shouldReplace
|
||||
? existingFactory.pluginId
|
||||
: pluginId;
|
||||
|
||||
options.collector.report({
|
||||
code: 'API_FACTORY_CONFLICT',
|
||||
message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
apiRefId,
|
||||
pluginId: rejectedPluginId,
|
||||
existingPluginId: acceptedPluginId,
|
||||
},
|
||||
});
|
||||
if (shouldReplace) {
|
||||
factoriesById.set(apiRefId, {
|
||||
pluginId,
|
||||
node: apiNode,
|
||||
factory: apiFactory,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
factoriesById.set(apiRefId, {
|
||||
pluginId,
|
||||
node: apiNode,
|
||||
factory: apiFactory,
|
||||
});
|
||||
} else {
|
||||
options.collector.report({
|
||||
code: 'API_EXTENSION_INVALID',
|
||||
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return factoriesById;
|
||||
}
|
||||
|
||||
// TODO(Rugvip): It would be good if this was more explicit, but I think that
|
||||
// might need to wait for some future update for API factories.
|
||||
function getApiOwnerId(apiRefId: string): string {
|
||||
const [prefix, ...rest] = apiRefId.split('.');
|
||||
if (!prefix) {
|
||||
return apiRefId;
|
||||
}
|
||||
if (prefix === 'core') {
|
||||
return 'app';
|
||||
}
|
||||
if (prefix === 'plugin' && rest[0]) {
|
||||
return rest[0];
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -82,6 +82,21 @@ export type AppErrorTypes = {
|
||||
existingPluginId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
|
||||
context: { node: AppNode };
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
|
||||
context: { node: AppNode; apiRefId: string };
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
apiRefId: string;
|
||||
bootstrapNode: AppNode;
|
||||
pluginId: string;
|
||||
bootstrapPluginId: string;
|
||||
};
|
||||
};
|
||||
// routing
|
||||
ROUTE_DUPLICATE: {
|
||||
context: { routeId: string };
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
FrontendFeature,
|
||||
FrontendPluginInfo,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,210 +14,28 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
ApiBlueprint,
|
||||
AppTree,
|
||||
AppTreeApi,
|
||||
appTreeApiRef,
|
||||
RouteRef,
|
||||
ExternalRouteRef,
|
||||
SubRouteRef,
|
||||
AnyRouteRefParams,
|
||||
RouteFunc,
|
||||
RouteResolutionApi,
|
||||
createApiFactory,
|
||||
routeResolutionApiRef,
|
||||
AppNode,
|
||||
FrontendFeature,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionFactoryMiddleware } from './types';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
featureFlagsApiRef,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
|
||||
import {
|
||||
createExtensionDataContainer,
|
||||
OpaqueFrontendPlugin,
|
||||
} from '@internal/frontend';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
resolveExtensionDefinition,
|
||||
toInternalExtension,
|
||||
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
|
||||
import {
|
||||
extractRouteInfoFromAppNode,
|
||||
RouteInfo,
|
||||
} from '../routing/extractRouteInfoFromAppNode';
|
||||
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { CreateAppRouteBinder } from '../routing';
|
||||
import { RouteResolver } from '../routing/RouteResolver';
|
||||
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { FrontendPluginInfoResolver } from './createPluginInfoAttacher';
|
||||
import {
|
||||
toInternalFrontendModule,
|
||||
isInternalFrontendModule,
|
||||
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
|
||||
import { getBasePath } from '../routing/getBasePath';
|
||||
import { Root } from '../extensions/Root';
|
||||
import { resolveAppTree } from '../tree/resolveAppTree';
|
||||
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
|
||||
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
|
||||
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
|
||||
import { BackstageRouteObject } from '../routing/types';
|
||||
import { matchRoutes } from 'react-router-dom';
|
||||
import {
|
||||
createPluginInfoAttacher,
|
||||
FrontendPluginInfoResolver,
|
||||
} from './createPluginInfoAttacher';
|
||||
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
|
||||
import {
|
||||
AppError,
|
||||
createErrorCollector,
|
||||
ErrorCollector,
|
||||
} from './createErrorCollector';
|
||||
createSessionStateFromApis,
|
||||
CreateSpecializedAppInternalOptions,
|
||||
FinalizedSpecializedApp,
|
||||
prepareSpecializedApp,
|
||||
} from './prepareSpecializedApp';
|
||||
|
||||
function deduplicateFeatures(
|
||||
allFeatures: FrontendFeature[],
|
||||
): FrontendFeature[] {
|
||||
// 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 (!OpaqueFrontendPlugin.isType(feature)) {
|
||||
return true;
|
||||
}
|
||||
if (seenIds.has(feature.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(feature.id);
|
||||
return true;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
class AppTreeApiProxy implements AppTreeApi {
|
||||
#routeInfo?: RouteInfo;
|
||||
private readonly tree: AppTree;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(tree: AppTree, appBasePath: string) {
|
||||
this.tree = tree;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
private checkIfInitialized() {
|
||||
if (!this.#routeInfo) {
|
||||
throw new Error(
|
||||
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getTree() {
|
||||
this.checkIfInitialized();
|
||||
|
||||
return { tree: this.tree };
|
||||
}
|
||||
|
||||
getNodesByRoutePath(routePath: string): { nodes: AppNode[] } {
|
||||
this.checkIfInitialized();
|
||||
|
||||
let path = routePath;
|
||||
if (path.startsWith(this.appBasePath)) {
|
||||
path = path.slice(this.appBasePath.length);
|
||||
}
|
||||
|
||||
const matchedRoutes = matchRoutes(this.#routeInfo!.routeObjects, path);
|
||||
|
||||
const matchedAppNodes =
|
||||
matchedRoutes
|
||||
?.filter(routeObj => !!routeObj.route.appNode)
|
||||
.map(routeObj => routeObj.route.appNode!) || [];
|
||||
|
||||
return { nodes: matchedAppNodes };
|
||||
}
|
||||
|
||||
initialize(routeInfo: RouteInfo) {
|
||||
this.#routeInfo = routeInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
class RouteResolutionApiProxy implements RouteResolutionApi {
|
||||
#delegate: RouteResolutionApi | undefined;
|
||||
#routeObjects: BackstageRouteObject[] | undefined;
|
||||
|
||||
private readonly routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>,
|
||||
appBasePath: string,
|
||||
) {
|
||||
this.routeBindings = routeBindings;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
resolve<TParams extends AnyRouteRefParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams>,
|
||||
options?: { sourcePath?: string },
|
||||
): RouteFunc<TParams> | undefined {
|
||||
if (!this.#delegate) {
|
||||
throw new Error(
|
||||
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.#delegate.resolve(anyRouteRef, options);
|
||||
}
|
||||
|
||||
initialize(
|
||||
routeInfo: RouteInfo,
|
||||
routeRefsById: Map<string, RouteRef | SubRouteRef>,
|
||||
) {
|
||||
this.#delegate = new RouteResolver(
|
||||
routeInfo.routePaths,
|
||||
routeInfo.routeParents,
|
||||
routeInfo.routeObjects,
|
||||
this.routeBindings,
|
||||
this.appBasePath,
|
||||
routeInfo.routeAliasResolver,
|
||||
routeRefsById,
|
||||
);
|
||||
this.#routeObjects = routeInfo.routeObjects;
|
||||
|
||||
return routeInfo;
|
||||
}
|
||||
|
||||
getRouteObjects() {
|
||||
return this.#routeObjects;
|
||||
}
|
||||
}
|
||||
export type { CreateSpecializedAppInternalOptions };
|
||||
|
||||
/**
|
||||
* Options for {@link createSpecializedApp}.
|
||||
*
|
||||
* @deprecated Use `PrepareSpecializedAppOptions` with `prepareSpecializedApp` instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CreateSpecializedAppOptions = {
|
||||
@@ -246,12 +64,7 @@ export type CreateSpecializedAppOptions = {
|
||||
*/
|
||||
advanced?: {
|
||||
/**
|
||||
* A replacement API holder implementation to use.
|
||||
*
|
||||
* By default, a new API holder will be constructed automatically based on
|
||||
* the other inputs. If you pass in a custom one here, none of that
|
||||
* automation will take place - so you will have to take care to supply all
|
||||
* those APIs yourself.
|
||||
* APIs to expose to the app during startup.
|
||||
*/
|
||||
apis?: ApiHolder;
|
||||
|
||||
@@ -273,252 +86,29 @@ export type CreateSpecializedAppOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
// Internal options type, not exported in the public API
|
||||
export interface CreateSpecializedAppInternalOptions
|
||||
extends CreateSpecializedAppOptions {
|
||||
__internal?: {
|
||||
apiFactoryOverrides?: AnyApiFactory[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty app without any default features. This is a low-level API is
|
||||
* intended for use in tests or specialized setups. Typically you want to use
|
||||
* `createApp` from `@backstage/frontend-defaults` instead.
|
||||
*
|
||||
* @deprecated Use `prepareSpecializedApp` instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
} {
|
||||
const internalOptions = options as CreateSpecializedAppInternalOptions;
|
||||
const config = options?.config ?? new ConfigReader({}, 'empty-config');
|
||||
const features = deduplicateFeatures(options?.features ?? []).map(
|
||||
createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
|
||||
);
|
||||
export function createSpecializedApp(
|
||||
options?: CreateSpecializedAppOptions,
|
||||
): FinalizedSpecializedApp {
|
||||
const sessionState = options?.advanced?.apis
|
||||
? createSessionStateFromApis(options.advanced.apis)
|
||||
: undefined;
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
const tree = resolveAppTree(
|
||||
'root',
|
||||
resolveAppNodeSpecs({
|
||||
features,
|
||||
builtinExtensions: [
|
||||
resolveExtensionDefinition(Root, { namespace: 'root' }),
|
||||
],
|
||||
parameters: readAppExtensionsConfig(config),
|
||||
forbidden: new Set(['root']),
|
||||
collector,
|
||||
}),
|
||||
collector,
|
||||
);
|
||||
|
||||
const factories = createApiFactories({ tree, collector });
|
||||
const appBasePath = getBasePath(config);
|
||||
const appTreeApi = new AppTreeApiProxy(tree, appBasePath);
|
||||
|
||||
const routeRefsById = collectRouteIds(features, collector);
|
||||
const routeResolutionApi = new RouteResolutionApiProxy(
|
||||
resolveRouteBindings(options?.bindRoutes, config, routeRefsById, collector),
|
||||
appBasePath,
|
||||
);
|
||||
|
||||
const appIdentityProxy = new AppIdentityProxy();
|
||||
const apis =
|
||||
options?.advanced?.apis ??
|
||||
createApiHolder({
|
||||
factories,
|
||||
staticFactories: [
|
||||
createApiFactory(appTreeApiRef, appTreeApi),
|
||||
createApiFactory(configApiRef, config),
|
||||
createApiFactory(routeResolutionApiRef, routeResolutionApi),
|
||||
createApiFactory(identityApiRef, appIdentityProxy),
|
||||
...(internalOptions?.__internal?.apiFactoryOverrides ?? []),
|
||||
],
|
||||
});
|
||||
|
||||
const featureFlagApi = apis.get(featureFlagsApiRef);
|
||||
if (featureFlagApi) {
|
||||
for (const feature of features) {
|
||||
if (OpaqueFrontendPlugin.isType(feature)) {
|
||||
OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (isInternalFrontendModule(feature)) {
|
||||
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.pluginId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now instantiate the entire tree, which will skip anything that's already been instantiated
|
||||
instantiateAppNodeTree(
|
||||
tree.root,
|
||||
apis,
|
||||
collector,
|
||||
mergeExtensionFactoryMiddleware(
|
||||
options?.advanced?.extensionFactoryMiddleware,
|
||||
),
|
||||
);
|
||||
|
||||
const routeInfo = extractRouteInfoFromAppNode(
|
||||
tree.root,
|
||||
createRouteAliasResolver(routeRefsById),
|
||||
);
|
||||
|
||||
routeResolutionApi.initialize(routeInfo, routeRefsById.routes);
|
||||
appTreeApi.initialize(routeInfo);
|
||||
|
||||
return { apis, tree, errors: collector.collectErrors() };
|
||||
}
|
||||
|
||||
function createApiFactories(options: {
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
}): AnyApiFactory[] {
|
||||
const emptyApiHolder = ApiRegistry.from([]);
|
||||
const factoriesById = new Map<
|
||||
string,
|
||||
{ pluginId: string; factory: AnyApiFactory }
|
||||
>();
|
||||
|
||||
for (const apiNode of options.tree.root.edges.attachments.get('apis') ?? []) {
|
||||
if (!instantiateAppNodeTree(apiNode, emptyApiHolder, options.collector)) {
|
||||
continue;
|
||||
}
|
||||
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
|
||||
if (apiFactory) {
|
||||
const apiRefId = apiFactory.api.id;
|
||||
const ownerId = getApiOwnerId(apiRefId);
|
||||
const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
|
||||
const existingFactory = factoriesById.get(apiRefId);
|
||||
|
||||
// This allows modules to override factories provided by the plugin, but
|
||||
// it rejects API overrides from other plugins. In the event of a
|
||||
// conflict, the owning plugin is attempted to be inferred from the API
|
||||
// reference ID.
|
||||
if (existingFactory && existingFactory.pluginId !== pluginId) {
|
||||
const shouldReplace =
|
||||
ownerId === pluginId && existingFactory.pluginId !== ownerId;
|
||||
const acceptedPluginId = shouldReplace
|
||||
? pluginId
|
||||
: existingFactory.pluginId;
|
||||
const rejectedPluginId = shouldReplace
|
||||
? existingFactory.pluginId
|
||||
: pluginId;
|
||||
|
||||
options.collector.report({
|
||||
code: 'API_FACTORY_CONFLICT',
|
||||
message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
apiRefId,
|
||||
pluginId: rejectedPluginId,
|
||||
existingPluginId: acceptedPluginId,
|
||||
},
|
||||
});
|
||||
if (shouldReplace) {
|
||||
factoriesById.set(apiRefId, {
|
||||
pluginId,
|
||||
factory: apiFactory,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
factoriesById.set(apiRefId, { pluginId, factory: apiFactory });
|
||||
} else {
|
||||
options.collector.report({
|
||||
code: 'API_EXTENSION_INVALID',
|
||||
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(factoriesById.values(), entry => entry.factory);
|
||||
}
|
||||
|
||||
// TODO(Rugvip): It would be good if this was more explicit, but I think that
|
||||
// might need to wait for some future update for API factories.
|
||||
function getApiOwnerId(apiRefId: string): string {
|
||||
const [prefix, ...rest] = apiRefId.split('.');
|
||||
if (!prefix) {
|
||||
return apiRefId;
|
||||
}
|
||||
if (prefix === 'core') {
|
||||
return 'app';
|
||||
}
|
||||
if (prefix === 'plugin' && rest[0]) {
|
||||
return rest[0];
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function createApiHolder(options: {
|
||||
factories: AnyApiFactory[];
|
||||
staticFactories: AnyApiFactory[];
|
||||
}): ApiHolder {
|
||||
const factoryRegistry = new ApiFactoryRegistry();
|
||||
|
||||
for (const factory of options.factories.slice().reverse()) {
|
||||
factoryRegistry.register('default', factory);
|
||||
}
|
||||
|
||||
for (const factory of options.staticFactories) {
|
||||
factoryRegistry.register('static', factory);
|
||||
}
|
||||
|
||||
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
|
||||
|
||||
return new ApiResolver(factoryRegistry);
|
||||
}
|
||||
|
||||
function mergeExtensionFactoryMiddleware(
|
||||
middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[],
|
||||
): ExtensionFactoryMiddleware | undefined {
|
||||
if (!middlewares) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(middlewares)) {
|
||||
return middlewares;
|
||||
}
|
||||
if (middlewares.length <= 1) {
|
||||
return middlewares[0];
|
||||
}
|
||||
return middlewares.reduce((prev, next) => {
|
||||
if (!prev || !next) {
|
||||
return prev ?? next;
|
||||
}
|
||||
return (orig, ctx) => {
|
||||
const internalExt = toInternalExtension(ctx.node.spec.extension);
|
||||
if (internalExt.version !== 'v2') {
|
||||
return orig();
|
||||
}
|
||||
return next(ctxOverrides => {
|
||||
return createExtensionDataContainer(
|
||||
prev(orig, {
|
||||
node: ctx.node,
|
||||
apis: ctx.apis,
|
||||
config: ctxOverrides?.config ?? ctx.config,
|
||||
}),
|
||||
'extension factory middleware',
|
||||
);
|
||||
}, ctx);
|
||||
};
|
||||
});
|
||||
return prepareSpecializedApp({
|
||||
features: options?.features,
|
||||
config: options?.config,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
advanced: {
|
||||
...options?.advanced,
|
||||
sessionState,
|
||||
},
|
||||
}).finalize();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
type BootstrapSpecializedApp,
|
||||
type FinalizedSpecializedApp,
|
||||
prepareSpecializedApp,
|
||||
type PrepareSpecializedAppOptions,
|
||||
type PreparedSpecializedApp,
|
||||
type SpecializedAppSessionState,
|
||||
} from './prepareSpecializedApp';
|
||||
export {
|
||||
createSpecializedApp,
|
||||
type CreateSpecializedAppOptions,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* 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 {
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
AppTree,
|
||||
AppTreeApi,
|
||||
appTreeApiRef,
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
ExternalRouteRef,
|
||||
identityApiRef,
|
||||
RouteFunc,
|
||||
RouteRef,
|
||||
RouteResolutionApi,
|
||||
routeResolutionApiRef,
|
||||
SubRouteRef,
|
||||
type AnyRouteRefParams,
|
||||
type AppNode,
|
||||
type ExtensionFactoryMiddleware,
|
||||
type IdentityApi,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { matchRoutes } from 'react-router-dom';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
|
||||
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
|
||||
import { RouteResolver } from '../routing/RouteResolver';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
import {
|
||||
extractRouteInfoFromAppNode,
|
||||
type RouteInfo,
|
||||
} from '../routing/extractRouteInfoFromAppNode';
|
||||
import { type BackstageRouteObject } from '../routing/types';
|
||||
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
|
||||
import {
|
||||
FrontendApiRegistry,
|
||||
FrontendApiResolver,
|
||||
} from './FrontendApiRegistry';
|
||||
import { type ExtensionPredicateContext } from './predicates';
|
||||
import { type ErrorCollector } from './createErrorCollector';
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
export class AppTreeApiProxy implements AppTreeApi {
|
||||
#routeInfo?: RouteInfo;
|
||||
private readonly tree: AppTree;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(tree: AppTree, appBasePath: string) {
|
||||
this.tree = tree;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
private checkIfInitialized() {
|
||||
if (!this.#routeInfo) {
|
||||
throw new Error(
|
||||
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getTree() {
|
||||
this.checkIfInitialized();
|
||||
|
||||
return { tree: this.tree };
|
||||
}
|
||||
|
||||
getNodesByRoutePath(routePath: string): { nodes: AppNode[] } {
|
||||
this.checkIfInitialized();
|
||||
const routeInfo = this.#routeInfo;
|
||||
if (!routeInfo) {
|
||||
throw new Error(
|
||||
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
|
||||
let path = routePath;
|
||||
if (path.startsWith(this.appBasePath)) {
|
||||
path = path.slice(this.appBasePath.length);
|
||||
}
|
||||
|
||||
const matchedRoutes = matchRoutes(routeInfo.routeObjects, path);
|
||||
|
||||
const matchedAppNodes =
|
||||
matchedRoutes?.flatMap(routeObj => {
|
||||
const appNode = routeObj.route.appNode;
|
||||
return appNode ? [appNode] : [];
|
||||
}) || [];
|
||||
|
||||
return { nodes: matchedAppNodes };
|
||||
}
|
||||
|
||||
initialize(routeInfo: RouteInfo) {
|
||||
this.#routeInfo = routeInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
export class RouteResolutionApiProxy implements RouteResolutionApi {
|
||||
#delegate: RouteResolutionApi | undefined;
|
||||
#routeObjects: BackstageRouteObject[] | undefined;
|
||||
|
||||
private readonly routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>,
|
||||
appBasePath: string,
|
||||
) {
|
||||
this.routeBindings = routeBindings;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
resolve<TParams extends AnyRouteRefParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams>,
|
||||
options?: { sourcePath?: string },
|
||||
): RouteFunc<TParams> | undefined {
|
||||
if (!this.#delegate) {
|
||||
throw new Error(
|
||||
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.#delegate.resolve(anyRouteRef, options);
|
||||
}
|
||||
|
||||
initialize(
|
||||
routeInfo: RouteInfo,
|
||||
routeRefsById: Map<string, RouteRef | SubRouteRef>,
|
||||
) {
|
||||
this.#delegate = new RouteResolver(
|
||||
routeInfo.routePaths,
|
||||
routeInfo.routeParents,
|
||||
routeInfo.routeObjects,
|
||||
this.routeBindings,
|
||||
this.appBasePath,
|
||||
routeInfo.routeAliasResolver,
|
||||
routeRefsById,
|
||||
);
|
||||
this.#routeObjects = routeInfo.routeObjects;
|
||||
|
||||
return routeInfo;
|
||||
}
|
||||
|
||||
getRouteObjects() {
|
||||
return this.#routeObjects;
|
||||
}
|
||||
}
|
||||
|
||||
export class PreparedAppIdentityProxy extends AppIdentityProxy {
|
||||
#onTargetSet?:
|
||||
| ((identityApi: Parameters<AppIdentityProxy['setTarget']>[0]) => void)
|
||||
| undefined;
|
||||
|
||||
setTargetHandlers(options: {
|
||||
onTargetSet?(
|
||||
identityApi: Parameters<AppIdentityProxy['setTarget']>[0],
|
||||
): void;
|
||||
}) {
|
||||
this.#onTargetSet = options.onTargetSet;
|
||||
}
|
||||
|
||||
clearTargetHandlers() {
|
||||
this.#onTargetSet = undefined;
|
||||
}
|
||||
|
||||
override setTarget(
|
||||
identityApi: Parameters<AppIdentityProxy['setTarget']>[0],
|
||||
targetOptions: Parameters<AppIdentityProxy['setTarget']>[1],
|
||||
) {
|
||||
super.setTarget(identityApi, targetOptions);
|
||||
|
||||
const onTargetSet = this.#onTargetSet;
|
||||
if (!onTargetSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearTargetHandlers();
|
||||
onTargetSet(identityApi);
|
||||
}
|
||||
}
|
||||
|
||||
export function createPhaseApis(options: {
|
||||
tree: AppTree;
|
||||
config: ConfigApi;
|
||||
appApiRegistry: FrontendApiRegistry;
|
||||
fallbackApis?: ApiHolder;
|
||||
includeConfigApi: boolean;
|
||||
appBasePath: string;
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
|
||||
staticFactories: AnyApiFactory[];
|
||||
}) {
|
||||
const appTreeApi = new AppTreeApiProxy(options.tree, options.appBasePath);
|
||||
const routeResolutionApi = new RouteResolutionApiProxy(
|
||||
options.routeBindings,
|
||||
options.appBasePath,
|
||||
);
|
||||
const identityProxy = new PreparedAppIdentityProxy();
|
||||
const phaseApiRegistry = new FrontendApiRegistry();
|
||||
phaseApiRegistry.registerAll([
|
||||
createApiFactory(appTreeApiRef, appTreeApi),
|
||||
...(options.includeConfigApi
|
||||
? [createApiFactory(configApiRef, options.config)]
|
||||
: []),
|
||||
createApiFactory(routeResolutionApiRef, routeResolutionApi),
|
||||
createApiFactory(identityApiRef, identityProxy),
|
||||
...options.staticFactories,
|
||||
]);
|
||||
|
||||
const apis = new FrontendApiResolver({
|
||||
primaryRegistry: phaseApiRegistry,
|
||||
secondaryRegistry: options.appApiRegistry,
|
||||
fallbackApis: options.fallbackApis,
|
||||
});
|
||||
|
||||
return {
|
||||
apis,
|
||||
routeResolutionApi,
|
||||
appTreeApi,
|
||||
identityApiProxy: identityProxy,
|
||||
};
|
||||
}
|
||||
|
||||
export function instantiateAndInitializePhaseTree(options: {
|
||||
tree: AppTree;
|
||||
apis: ApiHolder;
|
||||
collector: ErrorCollector;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
routeResolutionApi: RouteResolutionApiProxy;
|
||||
appTreeApi: AppTreeApiProxy;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
predicateContext?: ExtensionPredicateContext;
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
}) {
|
||||
instantiateAppNodeTree(
|
||||
options.tree.root,
|
||||
options.apis,
|
||||
options.collector,
|
||||
options.extensionFactoryMiddleware,
|
||||
{
|
||||
...(options.stopAtAttachment
|
||||
? { stopAtAttachment: options.stopAtAttachment }
|
||||
: {}),
|
||||
skipChild: options.skipChild,
|
||||
onMissingApi: options.onMissingApi,
|
||||
predicateContext: options.predicateContext,
|
||||
},
|
||||
);
|
||||
|
||||
const routeInfo = extractRouteInfoFromAppNode(
|
||||
options.tree.root,
|
||||
createRouteAliasResolver(options.routeRefsById),
|
||||
);
|
||||
|
||||
options.routeResolutionApi.initialize(
|
||||
routeInfo,
|
||||
options.routeRefsById.routes,
|
||||
);
|
||||
options.appTreeApi.initialize(routeInfo);
|
||||
}
|
||||
|
||||
export function setIdentityApiTarget(options: {
|
||||
identityApiProxy: AppIdentityProxy;
|
||||
identityApi: IdentityApi;
|
||||
signOutTargetUrl: string;
|
||||
}) {
|
||||
options.identityApiProxy.setTarget(options.identityApi, {
|
||||
signOutTargetUrl: options.signOutTargetUrl,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiHolder,
|
||||
createApiRef,
|
||||
featureFlagsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import type {
|
||||
EvaluatePermissionRequest,
|
||||
EvaluatePermissionResponse,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
|
||||
export type ExtensionPredicateContext = {
|
||||
featureFlags: string[];
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
export const EMPTY_PREDICATE_CONTEXT: ExtensionPredicateContext = {
|
||||
featureFlags: [],
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
// Minimal local permission API interface to avoid a dependency on @backstage/plugin-permission-react
|
||||
type MinimalPermissionApi = {
|
||||
authorize(
|
||||
request: EvaluatePermissionRequest,
|
||||
): Promise<EvaluatePermissionResponse>;
|
||||
};
|
||||
|
||||
export const localPermissionApiRef = createApiRef<MinimalPermissionApi>({
|
||||
id: 'plugin.permission.api',
|
||||
});
|
||||
|
||||
export function createPredicateContextLoader(options: {
|
||||
apis: ApiHolder;
|
||||
predicateReferences: ExtensionPredicateContext;
|
||||
}) {
|
||||
function getActiveFeatureFlags() {
|
||||
const featureFlagsApi = options.apis.get(featureFlagsApiRef);
|
||||
if (!featureFlagsApi) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return options.predicateReferences.featureFlags.filter(name =>
|
||||
featureFlagsApi.isActive(name),
|
||||
);
|
||||
}
|
||||
|
||||
function getImmediate(): ExtensionPredicateContext | undefined {
|
||||
if (options.predicateReferences.permissions.length > 0) {
|
||||
const permissionApi = options.apis.get(localPermissionApiRef);
|
||||
if (permissionApi) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
featureFlags: getActiveFeatureFlags(),
|
||||
permissions: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const immediatePredicateContext = getImmediate();
|
||||
if (immediatePredicateContext) {
|
||||
return immediatePredicateContext;
|
||||
}
|
||||
|
||||
let allowedPermissions: string[] = [];
|
||||
const permissionApi = options.apis.get(localPermissionApiRef);
|
||||
if (permissionApi) {
|
||||
const permissionNames = options.predicateReferences.permissions;
|
||||
const responses = await Promise.all(
|
||||
permissionNames.map(name =>
|
||||
permissionApi.authorize({
|
||||
permission: { name, type: 'basic', attributes: {} },
|
||||
}),
|
||||
),
|
||||
);
|
||||
allowedPermissions = permissionNames.filter(
|
||||
(_, i) => responses[i].result === 'ALLOW',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
featureFlags: getActiveFeatureFlags(),
|
||||
permissions: allowedPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getImmediate,
|
||||
load,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPredicateReferences(
|
||||
nodes: Iterable<{ spec: { if?: FilterPredicate } }>,
|
||||
): ExtensionPredicateContext {
|
||||
const featureFlags = new Set<string>();
|
||||
const permissions = new Set<string>();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.spec.if === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const name of extractFeatureFlagNames(node.spec.if)) {
|
||||
featureFlags.add(name);
|
||||
}
|
||||
for (const name of extractPermissionNames(node.spec.if)) {
|
||||
permissions.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
featureFlags: Array.from(featureFlags),
|
||||
permissions: Array.from(permissions),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks a FilterPredicate and returns all string values referenced
|
||||
* by `featureFlags: { $contains: '...' }` expressions. This lets us call
|
||||
* `isActive()` only for the flags that are actually used in predicates rather
|
||||
* than fetching the full registered-flag list.
|
||||
*/
|
||||
function extractFeatureFlagNames(predicate: FilterPredicate): string[] {
|
||||
return extractPredicateKeyNames(predicate, 'featureFlags');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks a FilterPredicate and returns all string values referenced
|
||||
* by `permissions: { $contains: '...' }` expressions. This lets us issue a
|
||||
* single batched authorize call for only the permissions actually referenced.
|
||||
*/
|
||||
function extractPermissionNames(predicate: FilterPredicate): string[] {
|
||||
return extractPredicateKeyNames(predicate, 'permissions');
|
||||
}
|
||||
|
||||
function extractPredicateKeyNames(
|
||||
predicate: FilterPredicate,
|
||||
key: string,
|
||||
): string[] {
|
||||
if (typeof predicate !== 'object' || predicate === null) {
|
||||
return [];
|
||||
}
|
||||
const obj = predicate as Record<string, unknown>;
|
||||
if (Array.isArray(obj.$all)) {
|
||||
return (obj.$all as FilterPredicate[]).flatMap(p =>
|
||||
extractPredicateKeyNames(p, key),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(obj.$any)) {
|
||||
return (obj.$any as FilterPredicate[]).flatMap(p =>
|
||||
extractPredicateKeyNames(p, key),
|
||||
);
|
||||
}
|
||||
if (obj.$not !== undefined) {
|
||||
return extractPredicateKeyNames(obj.$not as FilterPredicate, key);
|
||||
}
|
||||
const value = obj[key];
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
const contains = (value as Record<string, unknown>).$contains;
|
||||
if (typeof contains === 'string') {
|
||||
return [contains];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { isError } from '@backstage/errors';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
AppTree,
|
||||
ConfigApi,
|
||||
coreExtensionData,
|
||||
AppNode,
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
createExtensionDataRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
createExtensionDataContainer,
|
||||
OpaqueFrontendPlugin,
|
||||
} from '@internal/frontend';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
import { ComponentType, ReactNode } from 'react';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
resolveExtensionDefinition,
|
||||
toInternalExtension,
|
||||
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
|
||||
import { CreateAppRouteBinder } from '../routing';
|
||||
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
import { getBasePath } from '../routing/getBasePath';
|
||||
import { Root } from '../extensions/Root';
|
||||
import { resolveAppTree } from '../tree/resolveAppTree';
|
||||
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
|
||||
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
|
||||
import {
|
||||
createPluginInfoAttacher,
|
||||
FrontendPluginInfoResolver,
|
||||
} from './createPluginInfoAttacher';
|
||||
import {
|
||||
AppError,
|
||||
createErrorCollector,
|
||||
ErrorCollector,
|
||||
} from './createErrorCollector';
|
||||
import {
|
||||
createPhaseApis,
|
||||
instantiateAndInitializePhaseTree,
|
||||
setIdentityApiTarget,
|
||||
} from './phaseApis';
|
||||
import {
|
||||
collectPredicateReferences,
|
||||
createPredicateContextLoader,
|
||||
EMPTY_PREDICATE_CONTEXT,
|
||||
type ExtensionPredicateContext,
|
||||
} from './predicates';
|
||||
import { FrontendApiRegistry } from './FrontendApiRegistry';
|
||||
import {
|
||||
ApiFactoryEntry,
|
||||
collectApiFactoryEntries,
|
||||
registerFeatureFlagDeclarationsInHolder,
|
||||
syncFinalApiFactories,
|
||||
wrapFeatureFlagApiFactory,
|
||||
} from './apiFactories';
|
||||
import {
|
||||
attachThrowingFinalizationChild,
|
||||
BootstrapClassification,
|
||||
classifyBootstrapTree,
|
||||
clearFinalizationBoundaryInstances,
|
||||
createBootstrapApp,
|
||||
prepareFinalizedTree,
|
||||
} from './treeLifecycle';
|
||||
|
||||
function deduplicateFeatures(
|
||||
allFeatures: FrontendFeature[],
|
||||
): FrontendFeature[] {
|
||||
// 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 (!OpaqueFrontendPlugin.isType(feature)) {
|
||||
return true;
|
||||
}
|
||||
if (seenIds.has(feature.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(feature.id);
|
||||
return true;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
type SignInPageProps = {
|
||||
onSignInSuccess(identityApi: IdentityApi): void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of bootstrapping a prepared specialized app.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type BootstrapSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
tree: AppTree;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of finalizing a prepared specialized app.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type FinalizedSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
sessionState: SpecializedAppSessionState;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
|
||||
type SignInRuntime = {
|
||||
readyIdentityApi?: IdentityApi;
|
||||
requiresSignIn: boolean;
|
||||
};
|
||||
|
||||
type FinalizationState = {
|
||||
started: boolean;
|
||||
promise: Promise<FinalizedSpecializedApp>;
|
||||
resolve(app: FinalizedSpecializedApp): void;
|
||||
reject(error: unknown): void;
|
||||
};
|
||||
|
||||
type FinalizationMode = 'onFinalized' | 'finalize';
|
||||
|
||||
type InternalSpecializedAppSessionState = {
|
||||
apis: ApiHolder;
|
||||
identityApi?: IdentityApi;
|
||||
predicateContext: ExtensionPredicateContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opaque reusable session state for specialized apps.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SpecializedAppSessionState = {
|
||||
$$type: '@backstage/SpecializedAppSessionState';
|
||||
};
|
||||
|
||||
const OpaqueSpecializedAppSessionState = OpaqueType.create<{
|
||||
public: SpecializedAppSessionState;
|
||||
versions: InternalSpecializedAppSessionState & {
|
||||
version: 'v1';
|
||||
};
|
||||
}>({
|
||||
type: '@backstage/SpecializedAppSessionState',
|
||||
versions: ['v1'],
|
||||
});
|
||||
|
||||
const signInPageComponentDataRef = createExtensionDataRef<
|
||||
ComponentType<SignInPageProps>
|
||||
>().with({ id: 'core.sign-in-page.component' });
|
||||
|
||||
/**
|
||||
* Options for {@link prepareSpecializedApp}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PrepareSpecializedAppOptions = {
|
||||
/**
|
||||
* The list of features to load.
|
||||
*/
|
||||
features?: FrontendFeature[];
|
||||
|
||||
/**
|
||||
* The config API implementation to use. For most normal apps, this should be
|
||||
* specified.
|
||||
*
|
||||
* If none is given, a new _empty_ config will be used during startup. In
|
||||
* later stages of the app lifecycle, the config API in the API holder will be
|
||||
* used.
|
||||
*/
|
||||
config?: ConfigApi;
|
||||
|
||||
/**
|
||||
* Allows for the binding of plugins' external route refs within the app.
|
||||
*/
|
||||
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
|
||||
|
||||
/**
|
||||
* Advanced, more rarely used options.
|
||||
*/
|
||||
advanced?: {
|
||||
/**
|
||||
* A reusable specialized app session state to use.
|
||||
*
|
||||
* This can be obtained from either the app passed to
|
||||
* {@link PreparedSpecializedApp.onFinalized} or from
|
||||
* {@link PreparedSpecializedApp.finalize}, and reused in a future app
|
||||
* instance to skip sign-in and session preparation.
|
||||
*/
|
||||
sessionState?: SpecializedAppSessionState;
|
||||
|
||||
/**
|
||||
* Applies one or more middleware on every extension, as they are added to
|
||||
* the application.
|
||||
*
|
||||
* This is an advanced use case for modifying extension data on the fly as
|
||||
* it gets emitted by extensions being instantiated.
|
||||
*/
|
||||
extensionFactoryMiddleware?:
|
||||
| ExtensionFactoryMiddleware
|
||||
| ExtensionFactoryMiddleware[];
|
||||
|
||||
/**
|
||||
* Allows for customizing how plugin info is retrieved.
|
||||
*/
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of {@link prepareSpecializedApp}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PreparedSpecializedApp = {
|
||||
getBootstrapApp(): BootstrapSpecializedApp;
|
||||
onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
|
||||
finalize(): FinalizedSpecializedApp;
|
||||
};
|
||||
|
||||
// Internal options type, not exported in the public API
|
||||
export interface CreateSpecializedAppInternalOptions
|
||||
extends PrepareSpecializedAppOptions {
|
||||
__internal?: {
|
||||
apiFactoryOverrides?: AnyApiFactory[];
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionStateFromApis(
|
||||
apis: ApiHolder,
|
||||
): SpecializedAppSessionState {
|
||||
return OpaqueSpecializedAppSessionState.createInstance('v1', {
|
||||
apis,
|
||||
identityApi: apis.get(identityApiRef),
|
||||
predicateContext: EMPTY_PREDICATE_CONTEXT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares an app without instantiating the full extension tree.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This is useful for split sign-in flows where the sign-in page should be
|
||||
* rendered first, and the full app finalized once an identity has been
|
||||
* captured.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function prepareSpecializedApp(
|
||||
options?: PrepareSpecializedAppOptions,
|
||||
): PreparedSpecializedApp {
|
||||
const internalOptions = options as CreateSpecializedAppInternalOptions;
|
||||
const config = options?.config ?? new ConfigReader({}, 'empty-config');
|
||||
const features = deduplicateFeatures(options?.features ?? []).map(
|
||||
createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
|
||||
);
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
const tree = resolveAppTree(
|
||||
'root',
|
||||
resolveAppNodeSpecs({
|
||||
features,
|
||||
builtinExtensions: [
|
||||
resolveExtensionDefinition(Root, { namespace: 'root' }),
|
||||
],
|
||||
parameters: readAppExtensionsConfig(config),
|
||||
forbidden: new Set(['root']),
|
||||
collector,
|
||||
}),
|
||||
collector,
|
||||
);
|
||||
|
||||
const appBasePath = getBasePath(config);
|
||||
const routeRefsById = collectRouteIds(features, collector);
|
||||
const routeBindings = resolveRouteBindings(
|
||||
options?.bindRoutes,
|
||||
config,
|
||||
routeRefsById,
|
||||
collector,
|
||||
);
|
||||
|
||||
const mergedExtensionFactoryMiddleware = mergeExtensionFactoryMiddleware(
|
||||
options?.advanced?.extensionFactoryMiddleware,
|
||||
);
|
||||
const providedSessionState = options?.advanced?.sessionState;
|
||||
const providedSessionData = providedSessionState
|
||||
? OpaqueSpecializedAppSessionState.toInternal(providedSessionState)
|
||||
: undefined;
|
||||
const providedApis = providedSessionData?.apis;
|
||||
// Bootstrap only renders the parts of the tree that are known to be safe
|
||||
// before predicate context and sign-in have been resolved.
|
||||
const bootstrapClassification = classifyBootstrapTree({
|
||||
tree,
|
||||
collector,
|
||||
});
|
||||
const predicateReferences = collectPredicateReferences(tree.nodes.values());
|
||||
const appApiRegistry = new FrontendApiRegistry();
|
||||
const internalStaticFactories =
|
||||
internalOptions?.__internal?.apiFactoryOverrides ?? [];
|
||||
const phaseStaticFactories = [...internalStaticFactories];
|
||||
const bootstrapApiFactoryEntries = new Map<string, ApiFactoryEntry>();
|
||||
const bootstrapMissingApiAccesses = new Map<
|
||||
string,
|
||||
{ node: AppNode; apiRefId: string }
|
||||
>();
|
||||
|
||||
if (providedApis) {
|
||||
// Reused session state already carries a fully prepared API holder, so the
|
||||
// bootstrap path only needs to register feature flag declarations on top.
|
||||
registerFeatureFlagDeclarationsInHolder(providedApis, features);
|
||||
} else {
|
||||
// Bootstrap materializes only the immediately visible API factories. Any
|
||||
// predicate-gated API roots are revisited during finalization.
|
||||
collectApiFactoryEntries({
|
||||
apiNodes: (tree.root.edges.attachments.get('apis') ?? []).filter(
|
||||
apiNode => !bootstrapClassification.deferredApiRoots.has(apiNode),
|
||||
),
|
||||
collector,
|
||||
entries: bootstrapApiFactoryEntries,
|
||||
});
|
||||
const apiFactories = Array.from(
|
||||
bootstrapApiFactoryEntries.values(),
|
||||
entry => wrapFeatureFlagApiFactory(entry.factory, features),
|
||||
);
|
||||
appApiRegistry.registerAll(apiFactories);
|
||||
}
|
||||
const phase = createPhaseApis({
|
||||
tree,
|
||||
config,
|
||||
appApiRegistry,
|
||||
fallbackApis: providedApis,
|
||||
includeConfigApi: !providedApis,
|
||||
appBasePath,
|
||||
routeBindings,
|
||||
staticFactories: phaseStaticFactories,
|
||||
});
|
||||
const predicateContextLoader = createPredicateContextLoader({
|
||||
apis: phase.apis,
|
||||
predicateReferences,
|
||||
});
|
||||
let signInRuntime: SignInRuntime | undefined;
|
||||
let finalized: FinalizedSpecializedApp | undefined;
|
||||
let bootstrapApp: BootstrapSpecializedApp | undefined;
|
||||
|
||||
function updateIdentityApiTarget(identityApi?: IdentityApi) {
|
||||
if (!identityApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIdentityApiTarget({
|
||||
identityApiProxy: phase.identityApiProxy,
|
||||
identityApi,
|
||||
signOutTargetUrl: appBasePath || '/',
|
||||
});
|
||||
}
|
||||
|
||||
function createSessionState(predicateContext: ExtensionPredicateContext) {
|
||||
const identityApi =
|
||||
signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi;
|
||||
// As soon as a real identity is available we swap the phase proxy over so
|
||||
// the finalized tree observes the same API instance.
|
||||
updateIdentityApiTarget(identityApi);
|
||||
const sessionState = OpaqueSpecializedAppSessionState.createInstance('v1', {
|
||||
apis: phase.apis,
|
||||
identityApi,
|
||||
predicateContext,
|
||||
});
|
||||
return sessionState;
|
||||
}
|
||||
|
||||
function getSynchronousSessionState() {
|
||||
if (providedSessionState) {
|
||||
return providedSessionState;
|
||||
}
|
||||
// The direct finalize() path is intentionally synchronous. If sign-in is
|
||||
// still pending we refuse to guess and force the caller to wait.
|
||||
if (signInRuntime?.requiresSignIn) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const predicateContext = predicateContextLoader.getImmediate();
|
||||
if (!predicateContext) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return createSessionState(predicateContext);
|
||||
}
|
||||
|
||||
function loadAsyncSessionState() {
|
||||
if (providedSessionState) {
|
||||
return Promise.resolve(providedSessionState);
|
||||
}
|
||||
if (signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// For apps without sign-in we can sometimes finalize immediately from the
|
||||
// already available predicate context, skipping the async loader.
|
||||
if (!signInRuntime?.requiresSignIn) {
|
||||
const immediateSessionState = getSynchronousSessionState();
|
||||
if (immediateSessionState) {
|
||||
return Promise.resolve(immediateSessionState);
|
||||
}
|
||||
}
|
||||
|
||||
return predicateContextLoader.load().then(createSessionState);
|
||||
}
|
||||
|
||||
function finalizeWithSessionState(
|
||||
finalizedSessionState: SpecializedAppSessionState,
|
||||
) {
|
||||
return finalizeFromSessionState({
|
||||
finalized,
|
||||
finalizedSessionState,
|
||||
tree,
|
||||
collector,
|
||||
phase,
|
||||
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
|
||||
routeRefsById,
|
||||
appBasePath,
|
||||
providedApis,
|
||||
features,
|
||||
appApiRegistry,
|
||||
bootstrapClassification,
|
||||
bootstrapApiFactoryEntries,
|
||||
bootstrapMissingApiAccesses,
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeWithBootstrapError(
|
||||
error: Error,
|
||||
finalizedSessionState?: SpecializedAppSessionState,
|
||||
) {
|
||||
return finalizeFromBootstrapError({
|
||||
finalized,
|
||||
error,
|
||||
finalizedSessionState,
|
||||
tree,
|
||||
collector,
|
||||
phase,
|
||||
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
|
||||
routeRefsById,
|
||||
signInRuntime,
|
||||
providedSessionData,
|
||||
});
|
||||
}
|
||||
|
||||
const finalization = createFinalizationController({
|
||||
getFinalized() {
|
||||
return finalized;
|
||||
},
|
||||
setFinalized(finalizedApp) {
|
||||
finalized = finalizedApp;
|
||||
},
|
||||
finalizeFromSessionState: finalizeWithSessionState,
|
||||
finalizeFromBootstrapError: finalizeWithBootstrapError,
|
||||
});
|
||||
|
||||
function getBootstrapApp() {
|
||||
if (bootstrapApp) {
|
||||
return bootstrapApp;
|
||||
}
|
||||
|
||||
const runtime: SignInRuntime = {
|
||||
requiresSignIn: false,
|
||||
};
|
||||
if (!providedSessionState) {
|
||||
phase.identityApiProxy.setTargetHandlers({
|
||||
onTargetSet(identityApi) {
|
||||
runtime.readyIdentityApi = identityApi;
|
||||
// Sign-in completion only auto-starts finalization for onFinalized().
|
||||
// The direct finalize() path stays explicit and synchronous.
|
||||
if (finalization.getMode() === 'onFinalized') {
|
||||
finalization.start(loadAsyncSessionState);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = createBootstrapApp({
|
||||
tree,
|
||||
apis: phase.apis,
|
||||
collector,
|
||||
routeRefsById,
|
||||
routeResolutionApi: phase.routeResolutionApi,
|
||||
appTreeApi: phase.appTreeApi,
|
||||
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
|
||||
disableSignIn: Boolean(providedSessionState),
|
||||
skipBootstrapChild({ child }) {
|
||||
return bootstrapClassification.deferredRoots.has(child);
|
||||
},
|
||||
onMissingApi({ node, apiRefId }) {
|
||||
bootstrapMissingApiAccesses.set(`${node.spec.id}:${apiRefId}`, {
|
||||
node,
|
||||
apiRefId,
|
||||
});
|
||||
},
|
||||
hasSignInPage(signInPageNode) {
|
||||
return Boolean(
|
||||
signInPageNode?.instance?.getData(signInPageComponentDataRef),
|
||||
);
|
||||
},
|
||||
});
|
||||
if (!result.requiresSignIn) {
|
||||
phase.identityApiProxy.clearTargetHandlers();
|
||||
}
|
||||
|
||||
runtime.requiresSignIn = result.requiresSignIn;
|
||||
signInRuntime = runtime;
|
||||
bootstrapApp = result.bootstrapApp;
|
||||
|
||||
return bootstrapApp;
|
||||
}
|
||||
|
||||
return {
|
||||
getBootstrapApp,
|
||||
onFinalized(callback) {
|
||||
finalization.selectMode('onFinalized');
|
||||
// Subscribing to finalization also ensures the bootstrap tree exists,
|
||||
// because sign-in may need to capture identity before finalization starts.
|
||||
getBootstrapApp();
|
||||
|
||||
let subscribed = true;
|
||||
|
||||
if (finalized) {
|
||||
const finalizedApp = finalized;
|
||||
Promise.resolve().then(() => {
|
||||
if (subscribed) {
|
||||
callback(finalizedApp);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
subscribed = false;
|
||||
};
|
||||
}
|
||||
|
||||
// If sign-in is still in progress we wait for the shared promise created
|
||||
// by the sign-in callback. Otherwise we can start finalization right away.
|
||||
const finalizedAppPromise =
|
||||
signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi
|
||||
? finalization.getPromise()
|
||||
: finalization.start(loadAsyncSessionState);
|
||||
finalizedAppPromise
|
||||
.then(finalizedApp => {
|
||||
if (subscribed) {
|
||||
callback(finalizedApp);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
subscribed = false;
|
||||
};
|
||||
},
|
||||
finalize() {
|
||||
finalization.selectMode('finalize');
|
||||
if (finalized) {
|
||||
return finalized;
|
||||
}
|
||||
if (!providedSessionState) {
|
||||
// finalize() still depends on bootstrap classification and sign-in
|
||||
// discovery unless a reusable session was supplied up front, so we make
|
||||
// sure the bootstrap tree has been prepared first.
|
||||
getBootstrapApp();
|
||||
}
|
||||
|
||||
// Direct finalization never waits for async session preparation. Callers
|
||||
// must either provide sessionState during prepareSpecializedApp() or
|
||||
// invoke finalize() only when the predicate context is already available
|
||||
// synchronously.
|
||||
const finalizedSessionState = signInRuntime?.requiresSignIn
|
||||
? undefined
|
||||
: getSynchronousSessionState();
|
||||
if (!finalizedSessionState) {
|
||||
if (signInRuntime?.requiresSignIn) {
|
||||
throw new Error(
|
||||
'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()',
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
'prepareSpecializedApp requires waiting for asynchronous finalization before calling finalize()',
|
||||
);
|
||||
}
|
||||
|
||||
finalized = finalizeWithSessionState(finalizedSessionState);
|
||||
return finalized;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes the fully finalized app tree from a prepared session state.
|
||||
*
|
||||
* This is responsible for switching the identity proxy to the resolved target,
|
||||
* synchronizing any deferred API factories, and re-instantiating the parts of
|
||||
* the tree that are only valid once predicate context is available.
|
||||
*/
|
||||
function finalizeFromSessionState(options: {
|
||||
finalized?: FinalizedSpecializedApp;
|
||||
finalizedSessionState: SpecializedAppSessionState;
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
phase: ReturnType<typeof createPhaseApis>;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
appBasePath: string;
|
||||
providedApis?: ApiHolder;
|
||||
features: FrontendFeature[];
|
||||
appApiRegistry: FrontendApiRegistry;
|
||||
bootstrapClassification: BootstrapClassification;
|
||||
bootstrapApiFactoryEntries: Map<string, ApiFactoryEntry>;
|
||||
bootstrapMissingApiAccesses: Map<string, { node: AppNode; apiRefId: string }>;
|
||||
}): FinalizedSpecializedApp {
|
||||
if (options.finalized) {
|
||||
return options.finalized;
|
||||
}
|
||||
|
||||
const sessionStateData = OpaqueSpecializedAppSessionState.toInternal(
|
||||
options.finalizedSessionState,
|
||||
);
|
||||
if (sessionStateData.identityApi) {
|
||||
// Finalization retargets the identity proxy before any additional nodes are
|
||||
// instantiated so the full tree observes the captured identity immediately.
|
||||
setIdentityApiTarget({
|
||||
identityApiProxy: options.phase.identityApiProxy,
|
||||
identityApi: sessionStateData.identityApi,
|
||||
signOutTargetUrl: options.appBasePath || '/',
|
||||
});
|
||||
}
|
||||
if (!options.providedApis) {
|
||||
// Deferred API roots are synchronized at finalization time, but bootstrap-
|
||||
// materialized APIs stay frozen if they were already observed earlier.
|
||||
syncFinalApiFactories({
|
||||
deferredApiNodes: options.bootstrapClassification.deferredApiRoots,
|
||||
appApiRegistry: options.appApiRegistry,
|
||||
apiResolver: options.phase.apis,
|
||||
collector: options.collector,
|
||||
features: options.features,
|
||||
bootstrapApiFactoryEntries: options.bootstrapApiFactoryEntries,
|
||||
bootstrapMissingApiAccesses: options.bootstrapMissingApiAccesses,
|
||||
predicateContext: sessionStateData.predicateContext,
|
||||
});
|
||||
}
|
||||
|
||||
prepareFinalizedTree({
|
||||
tree: options.tree,
|
||||
});
|
||||
// Finalization re-instantiates the boundary subtree so predicate-gated app
|
||||
// content can be re-evaluated without disturbing preserved bootstrap nodes.
|
||||
clearFinalizationBoundaryInstances(options.tree);
|
||||
instantiateAndInitializePhaseTree({
|
||||
tree: options.tree,
|
||||
apis: options.phase.apis,
|
||||
collector: options.collector,
|
||||
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
|
||||
routeResolutionApi: options.phase.routeResolutionApi,
|
||||
appTreeApi: options.phase.appTreeApi,
|
||||
routeRefsById: options.routeRefsById,
|
||||
predicateContext: sessionStateData.predicateContext,
|
||||
});
|
||||
|
||||
const element = options.tree.root.instance?.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error('Expected finalized app tree to expose a root element');
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
sessionState: options.finalizedSessionState,
|
||||
tree: options.tree,
|
||||
errors: options.collector.collectErrors(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a finalized app that rethrows a bootstrap-time failure through the
|
||||
* normal app root boundary.
|
||||
*
|
||||
* This keeps the error handling path aligned with normal finalization while
|
||||
* preserving any session state that was already resolved before the failure.
|
||||
*/
|
||||
function finalizeFromBootstrapError(options: {
|
||||
finalized?: FinalizedSpecializedApp;
|
||||
error: Error;
|
||||
finalizedSessionState?: SpecializedAppSessionState;
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
phase: ReturnType<typeof createPhaseApis>;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
signInRuntime?: SignInRuntime;
|
||||
providedSessionData?: InternalSpecializedAppSessionState;
|
||||
}): FinalizedSpecializedApp {
|
||||
if (options.finalized) {
|
||||
return options.finalized;
|
||||
}
|
||||
|
||||
// If finalization fails after session state was already prepared, keep using
|
||||
// it so the error app reflects the same identity and API view.
|
||||
const finalizedSessionState =
|
||||
options.finalizedSessionState ??
|
||||
OpaqueSpecializedAppSessionState.createInstance('v1', {
|
||||
apis: options.phase.apis,
|
||||
identityApi:
|
||||
options.signInRuntime?.readyIdentityApi ??
|
||||
options.providedSessionData?.identityApi,
|
||||
predicateContext: EMPTY_PREDICATE_CONTEXT,
|
||||
});
|
||||
|
||||
prepareFinalizedTree({
|
||||
tree: options.tree,
|
||||
});
|
||||
clearFinalizationBoundaryInstances(options.tree);
|
||||
// The final app reports bootstrap failures through app/root.children so the
|
||||
// normal app root boundary renders the error state for us.
|
||||
attachThrowingFinalizationChild(options.tree, options.error);
|
||||
instantiateAndInitializePhaseTree({
|
||||
tree: options.tree,
|
||||
apis: options.phase.apis,
|
||||
collector: options.collector,
|
||||
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
|
||||
routeResolutionApi: options.phase.routeResolutionApi,
|
||||
appTreeApi: options.phase.appTreeApi,
|
||||
routeRefsById: options.routeRefsById,
|
||||
});
|
||||
|
||||
const element = options.tree.root.instance?.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error('Expected finalized app tree to expose a root element');
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
sessionState: finalizedSessionState,
|
||||
tree: options.tree,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the callback-driven finalization lifecycle for a prepared app.
|
||||
*
|
||||
* The controller enforces the selected finalization mode, memoizes the shared
|
||||
* async finalization promise for `onFinalized()` subscribers, and funnels both
|
||||
* successful and failing async finalization through the same resolution path.
|
||||
*/
|
||||
function createFinalizationController(options: {
|
||||
getFinalized(): FinalizedSpecializedApp | undefined;
|
||||
setFinalized(finalizedApp: FinalizedSpecializedApp): void;
|
||||
finalizeFromSessionState(
|
||||
finalizedSessionState: SpecializedAppSessionState,
|
||||
): FinalizedSpecializedApp;
|
||||
finalizeFromBootstrapError(
|
||||
error: Error,
|
||||
finalizedSessionState?: SpecializedAppSessionState,
|
||||
): FinalizedSpecializedApp;
|
||||
}) {
|
||||
let finalizationState: FinalizationState | undefined;
|
||||
let finalizationMode: FinalizationMode | undefined;
|
||||
|
||||
function getState(): FinalizationState {
|
||||
if (finalizationState) {
|
||||
return finalizationState;
|
||||
}
|
||||
|
||||
// onFinalized() subscribers all fan into the same promise so that the full
|
||||
// finalization flow only ever runs once.
|
||||
let resolve: ((app: FinalizedSpecializedApp) => void) | undefined;
|
||||
let reject: ((error: unknown) => void) | undefined;
|
||||
const promise = new Promise<FinalizedSpecializedApp>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
if (!resolve || !reject) {
|
||||
throw new Error('Failed to create finalization state');
|
||||
}
|
||||
|
||||
finalizationState = {
|
||||
started: false,
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
return finalizationState;
|
||||
}
|
||||
|
||||
return {
|
||||
getMode() {
|
||||
return finalizationMode;
|
||||
},
|
||||
getPromise() {
|
||||
return getState().promise;
|
||||
},
|
||||
selectMode(mode: FinalizationMode) {
|
||||
if (finalizationMode && finalizationMode !== mode) {
|
||||
throw new Error(
|
||||
`prepareSpecializedApp only supports using either onFinalized() or finalize(), not both`,
|
||||
);
|
||||
}
|
||||
|
||||
// A prepared app now has one owner: either the callback-driven path or
|
||||
// the direct finalize() path, never both.
|
||||
finalizationMode = mode;
|
||||
},
|
||||
start(loader: () => Promise<SpecializedAppSessionState>) {
|
||||
const finalized = options.getFinalized();
|
||||
if (finalized) {
|
||||
return Promise.resolve(finalized);
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
if (state.started) {
|
||||
return state.promise;
|
||||
}
|
||||
state.started = true;
|
||||
|
||||
// If loading finishes but final tree materialization fails, we still
|
||||
// want to preserve the resolved session state when building the error app.
|
||||
let finalizedSessionState: SpecializedAppSessionState | undefined;
|
||||
loader()
|
||||
.then(sessionState => {
|
||||
finalizedSessionState = sessionState;
|
||||
const finalizedApp = options.finalizeFromSessionState(sessionState);
|
||||
options.setFinalized(finalizedApp);
|
||||
state.resolve(finalizedApp);
|
||||
})
|
||||
.catch(error => {
|
||||
try {
|
||||
const bootstrapFailure = isError(error)
|
||||
? error
|
||||
: new Error(String(error));
|
||||
const finalizedApp = options.finalizeFromBootstrapError(
|
||||
bootstrapFailure,
|
||||
finalizedSessionState,
|
||||
);
|
||||
options.setFinalized(finalizedApp);
|
||||
state.resolve(finalizedApp);
|
||||
} catch (finalizationError) {
|
||||
finalizationState = undefined;
|
||||
state.reject(finalizationError);
|
||||
}
|
||||
});
|
||||
|
||||
return state.promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines one or more extension factory middlewares into a single middleware
|
||||
* invocation chain that preserves Backstage's extension data container shape.
|
||||
*/
|
||||
function mergeExtensionFactoryMiddleware(
|
||||
middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[],
|
||||
): ExtensionFactoryMiddleware | undefined {
|
||||
if (!middlewares) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(middlewares)) {
|
||||
return middlewares;
|
||||
}
|
||||
if (middlewares.length <= 1) {
|
||||
return middlewares[0];
|
||||
}
|
||||
return middlewares.reduce((prev, next) => {
|
||||
if (!prev || !next) {
|
||||
return prev ?? next;
|
||||
}
|
||||
return (orig, ctx) => {
|
||||
const internalExt = toInternalExtension(ctx.node.spec.extension);
|
||||
if (internalExt.version !== 'v2') {
|
||||
return orig();
|
||||
}
|
||||
return next(ctxOverrides => {
|
||||
return createExtensionDataContainer(
|
||||
prev(orig, {
|
||||
node: ctx.node,
|
||||
apis: ctx.apis,
|
||||
config: ctxOverrides?.config ?? ctx.config,
|
||||
}),
|
||||
'extension factory middleware',
|
||||
);
|
||||
}, ctx);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
AppNodeInstance,
|
||||
AppTree,
|
||||
coreExtensionData,
|
||||
ExtensionDataRef,
|
||||
ExtensionFactoryMiddleware,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
import { ErrorCollector } from './createErrorCollector';
|
||||
import {
|
||||
AppTreeApiProxy,
|
||||
instantiateAndInitializePhaseTree,
|
||||
RouteResolutionApiProxy,
|
||||
} from './phaseApis';
|
||||
|
||||
export type BootstrapClassification = {
|
||||
deferredApiRoots: Set<AppNode>;
|
||||
deferredElementRoots: Set<AppNode>;
|
||||
deferredRoots: Set<AppNode>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Instantiates the bootstrap-visible portion of the app tree and returns the
|
||||
* element that should be rendered while the prepared app is still incomplete.
|
||||
*
|
||||
* The bootstrap tree deliberately stops at the session boundary so sign-in and
|
||||
* other deferred content can be handled separately during finalization.
|
||||
*/
|
||||
export function createBootstrapApp(options: {
|
||||
tree: AppTree;
|
||||
apis: ApiHolder;
|
||||
collector: ErrorCollector;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
routeResolutionApi: RouteResolutionApiProxy;
|
||||
appTreeApi: AppTreeApiProxy;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
disableSignIn?: boolean;
|
||||
skipBootstrapChild?(ctx: {
|
||||
node: AppNode;
|
||||
input: string;
|
||||
child: AppNode;
|
||||
}): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
hasSignInPage(node?: AppNode): boolean;
|
||||
}): {
|
||||
bootstrapApp: { element: JSX.Element; tree: AppTree };
|
||||
requiresSignIn: boolean;
|
||||
} {
|
||||
const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get(
|
||||
'signInPage',
|
||||
)?.[0];
|
||||
|
||||
instantiateAndInitializePhaseTree({
|
||||
tree: options.tree,
|
||||
apis: options.apis,
|
||||
collector: options.collector,
|
||||
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
|
||||
routeResolutionApi: options.routeResolutionApi,
|
||||
appTreeApi: options.appTreeApi,
|
||||
routeRefsById: options.routeRefsById,
|
||||
stopAtAttachment: ({ node, input }) =>
|
||||
isSessionBoundaryAttachment(node, input),
|
||||
skipChild: options.skipBootstrapChild,
|
||||
onMissingApi: options.onMissingApi,
|
||||
});
|
||||
|
||||
const element = options.tree.root.instance?.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error('Expected bootstrap tree to expose a root element');
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrapApp: {
|
||||
element,
|
||||
tree: options.tree,
|
||||
},
|
||||
requiresSignIn:
|
||||
!options.disableSignIn && options.hasSignInPage(signInPageNode),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the app tree into bootstrap-visible and deferred regions.
|
||||
*
|
||||
* Predicate-gated roots are deferred to finalization, while any predicate that
|
||||
* still leaks into the bootstrap-visible region is reported and ignored.
|
||||
*/
|
||||
export function classifyBootstrapTree(options: {
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
}): BootstrapClassification {
|
||||
const apiNodes = options.tree.root.edges.attachments.get('apis') ?? [];
|
||||
const deferredApiRoots = new Set(
|
||||
apiNodes.filter(apiNode => subtreeContainsPredicate(apiNode)),
|
||||
);
|
||||
const appRootElementNodes =
|
||||
getAppRootNode(options.tree)?.edges.attachments.get('elements') ?? [];
|
||||
const deferredElementRoots = new Set(
|
||||
appRootElementNodes.filter(elementNode =>
|
||||
subtreeContainsPredicate(elementNode),
|
||||
),
|
||||
);
|
||||
const deferredRoots = new Set<AppNode>([
|
||||
...deferredApiRoots,
|
||||
...deferredElementRoots,
|
||||
]);
|
||||
const bootstrapNodes = collectBootstrapVisibleNodes(options.tree, {
|
||||
deferredRoots,
|
||||
});
|
||||
|
||||
for (const node of bootstrapNodes) {
|
||||
if (node.spec.if === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.collector.report({
|
||||
code: 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED',
|
||||
message:
|
||||
`Extension '${node.spec.id}' uses 'if' during bootstrap, so the predicate was ignored. ` +
|
||||
"Move it behind 'app/root.children', onto a deferred 'app/root.elements' subtree, or into an API subtree.",
|
||||
context: {
|
||||
node,
|
||||
},
|
||||
});
|
||||
(node.spec as typeof node.spec & { if?: FilterPredicate }).if = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
deferredApiRoots,
|
||||
deferredElementRoots,
|
||||
deferredRoots,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the app tree for finalization by removing the bootstrap-only
|
||||
* sign-in attachment from the app root boundary.
|
||||
*/
|
||||
export function prepareFinalizedTree(options: { tree: AppTree }) {
|
||||
for (const appRootNode of getFinalizationBoundaryNodes(options.tree)) {
|
||||
const attachments = appRootNode.edges.attachments as Map<string, AppNode[]>;
|
||||
attachments.delete('signInPage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears instances inside the finalization boundary so those nodes can be
|
||||
* re-instantiated with finalized predicate context and API availability.
|
||||
*/
|
||||
export function clearFinalizationBoundaryInstances(tree: AppTree) {
|
||||
clearNodeInstance(tree.root);
|
||||
|
||||
const visited = new Set<AppNode>();
|
||||
function visit(node: AppNode) {
|
||||
if (visited.has(node)) {
|
||||
return;
|
||||
}
|
||||
visited.add(node);
|
||||
clearNodeInstance(node);
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
// app/root.elements is allowed to keep its bootstrap instances so we only
|
||||
// re-run the parts of the boundary that actually change at finalization.
|
||||
if (node.spec.id === 'app/root' && input === 'elements') {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const appRootNode of getFinalizationBoundaryNodes(tree)) {
|
||||
visit(appRootNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the attachment that separates bootstrap rendering from the
|
||||
* children that are deferred until finalization.
|
||||
*/
|
||||
export function isSessionBoundaryAttachment(node: AppNode, input: string) {
|
||||
return node.spec.id === 'app/root' && input === 'children';
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a synthetic finalization child that throws the captured bootstrap
|
||||
* error when rendered.
|
||||
*
|
||||
* This lets the finalized tree reuse the normal app root error boundary rather
|
||||
* than introducing a separate error rendering path.
|
||||
*/
|
||||
export function attachThrowingFinalizationChild(tree: AppTree, error: Error) {
|
||||
const bootstrapChildNode =
|
||||
getAppRootNode(tree)?.edges.attachments.get('children')?.[0];
|
||||
if (!bootstrapChildNode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
function ThrowBootstrapError(): never {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// This synthetic child gives the finalized tree a stable place to rethrow
|
||||
// bootstrap failures through the normal extension boundary stack.
|
||||
(bootstrapChildNode as AppNode & { instance?: AppNodeInstance }).instance = {
|
||||
getDataRefs() {
|
||||
return [coreExtensionData.reactElement];
|
||||
},
|
||||
getData<TValue>(dataRef: ExtensionDataRef<TValue>) {
|
||||
if (dataRef.id === coreExtensionData.reactElement.id) {
|
||||
return (<ThrowBootstrapError />) as TValue;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getAppRootNode(tree: AppTree) {
|
||||
return tree.nodes.get('app/root');
|
||||
}
|
||||
|
||||
function getFinalizationBoundaryNodes(tree: AppTree): AppNode[] {
|
||||
const nodes = new Set<AppNode>();
|
||||
const appRootNode = getAppRootNode(tree);
|
||||
if (appRootNode) {
|
||||
nodes.add(appRootNode);
|
||||
}
|
||||
const attachedAppRootNode = tree.root.edges.attachments.get('app')?.[0];
|
||||
if (attachedAppRootNode) {
|
||||
nodes.add(attachedAppRootNode);
|
||||
}
|
||||
return Array.from(nodes);
|
||||
}
|
||||
|
||||
function clearNodeInstance(node: AppNode) {
|
||||
(node as AppNode & { instance?: AppNodeInstance }).instance = undefined;
|
||||
}
|
||||
|
||||
function collectBootstrapVisibleNodes(
|
||||
tree: AppTree,
|
||||
options?: { deferredRoots?: Set<AppNode> },
|
||||
) {
|
||||
const visibleNodes = new Set<AppNode>();
|
||||
|
||||
function visit(node: AppNode) {
|
||||
if (visibleNodes.has(node)) {
|
||||
return;
|
||||
}
|
||||
visibleNodes.add(node);
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
if (isSessionBoundaryAttachment(node, input)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
if (options?.deferredRoots?.has(child)) {
|
||||
continue;
|
||||
}
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(tree.root);
|
||||
|
||||
return visibleNodes;
|
||||
}
|
||||
|
||||
function subtreeContainsPredicate(root: AppNode) {
|
||||
const visited = new Set<AppNode>();
|
||||
|
||||
function visit(node: AppNode): boolean {
|
||||
if (visited.has(node)) {
|
||||
return false;
|
||||
}
|
||||
visited.add(node);
|
||||
|
||||
if (node.spec.if !== undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const children of node.edges.attachments.values()) {
|
||||
for (const child of children) {
|
||||
if (visit(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return visit(root);
|
||||
}
|
||||
@@ -43,6 +43,8 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-app-react": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/plugin-permission-react": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
|
||||
import {
|
||||
AppTreeApi,
|
||||
ApiBlueprint,
|
||||
appTreeApiRef,
|
||||
coreExtensionData,
|
||||
createApiRef,
|
||||
createExtensionDataRef,
|
||||
createExtension,
|
||||
createExtensionBlueprint,
|
||||
createExtensionInput,
|
||||
PageBlueprint,
|
||||
createFrontendPlugin,
|
||||
createFrontendFeatureLoader,
|
||||
@@ -27,12 +32,22 @@ import {
|
||||
FrontendPluginInfo,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ThemeBlueprint } from '@backstage/plugin-app-react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import { createApp } from './createApp';
|
||||
import { mockApis, renderWithEffects } from '@backstage/test-utils';
|
||||
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
featureFlagsApiRef,
|
||||
IdentityApi,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { default as appPluginOriginal } from '@backstage/plugin-app';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ComponentType, useState, useEffect } from 'react';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
|
||||
const signInPageComponentDataRef = createExtensionDataRef<
|
||||
ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
|
||||
>().with({ id: 'core.sign-in-page.component' });
|
||||
|
||||
describe('createApp', () => {
|
||||
const appPlugin = appPluginOriginal.withOverrides({
|
||||
@@ -43,6 +58,25 @@ describe('createApp', () => {
|
||||
],
|
||||
});
|
||||
|
||||
function createFeatureFlagsApi(activeFlags: string[]) {
|
||||
return {
|
||||
isActive: jest.fn((name: string) => activeFlags.includes(name)),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
}
|
||||
|
||||
function createPermissionApi(allowedPermissions: string[]) {
|
||||
return {
|
||||
authorize: jest.fn(async request => ({
|
||||
result: allowedPermissions.includes(request.permission.name)
|
||||
? AuthorizeResult.ALLOW
|
||||
: AuthorizeResult.DENY,
|
||||
})),
|
||||
} as typeof permissionApiRef.T;
|
||||
}
|
||||
|
||||
it('should allow themes to be installed', async () => {
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
@@ -84,6 +118,184 @@ describe('createApp', () => {
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide app APIs to sign-in pages before finalization', async () => {
|
||||
const signInApiRef = createApiRef<{ value: string }>({
|
||||
id: 'test.sign-in-api',
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: signInApiRef,
|
||||
deps: {},
|
||||
factory: () => ({ value: 'ok' }),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
const SignInPage = () => {
|
||||
const api = useApi(signInApiRef);
|
||||
return <div>Sign In API: {api.value}</div>;
|
||||
};
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Sign In API: ok'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide feature flags to sign-in pages before finalization', async () => {
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
const SignInPage = () => {
|
||||
const flagsApi = useApi(featureFlagsApiRef);
|
||||
return (
|
||||
<div>
|
||||
Flags:{' '}
|
||||
{flagsApi
|
||||
.getRegisteredFlags()
|
||||
.map(flag => flag.name)
|
||||
.join(', ')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Flags: test-flag'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should surface sign-in bootstrap errors through the app root boundary', async () => {
|
||||
const identityApi = {
|
||||
getProfileInfo: async () => ({ displayName: 'Test User' }),
|
||||
getBackstageIdentity: async () => ({
|
||||
type: 'user' as const,
|
||||
userEntityRef: 'user:default/test-user',
|
||||
ownershipEntityRefs: ['user:default/test-user'],
|
||||
}),
|
||||
getCredentials: async () => ({ token: 'token' }),
|
||||
signOut: async () => {},
|
||||
};
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn(() => {
|
||||
throw new Error('sign-in bootstrap failed');
|
||||
}),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
let onSignInSuccess: ((identity: IdentityApi) => void) | undefined;
|
||||
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
function SignInPage(props: {
|
||||
onSignInSuccess(identity: IdentityApi): void;
|
||||
}) {
|
||||
onSignInSuccess = props.onSignInSuccess;
|
||||
|
||||
return <div>Custom Sign In</div>;
|
||||
}
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Flagged Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Custom Sign In'),
|
||||
).resolves.toBeInTheDocument();
|
||||
if (!onSignInSuccess) {
|
||||
throw new Error('Expected sign-in success callback to be captured');
|
||||
}
|
||||
const triggerSignInSuccess = onSignInSuccess;
|
||||
act(() => {
|
||||
triggerSignInSuccess(identityApi);
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('sign-in bootstrap failed'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should deduplicate features keeping the last received one', async () => {
|
||||
const duplicatedFeatureId = 'test';
|
||||
const app = createApp({
|
||||
@@ -283,47 +495,497 @@ describe('createApp', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should warn about unknown extension config', async () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
it('should evaluate extension if predicates before rendering apps without sign-in', async () => {
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn((name: string) => name === 'test-flag'),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Derp</div>,
|
||||
loader: async () => <div>Flagged Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
advanced: {
|
||||
configLoader: async () => ({
|
||||
config: mockApis.config({
|
||||
data: {
|
||||
app: {
|
||||
extensions: [{ 'unknown:lols/wut': false }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
expect(warnSpy).toHaveBeenCalledWith('App startup encountered warnings:');
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'INVALID_EXTENSION_CONFIG_KEY: Extension unknown:lols/wut does not exist',
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
await expect(
|
||||
screen.findByText('Flagged Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag');
|
||||
});
|
||||
|
||||
it('should support $all feature flag predicates on pages', async () => {
|
||||
const partialFlagsApi = createFeatureFlagsApi(['experimental-features']);
|
||||
const partialFlagsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => partialFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'advanced-features' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>All Flags Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const partialRender = await renderWithEffects(partialFlagsApp.createRoot());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('All Flags Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
partialRender.unmount();
|
||||
|
||||
const allFlagsApi = createFeatureFlagsApi([
|
||||
'experimental-features',
|
||||
'advanced-features',
|
||||
]);
|
||||
const allFlagsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => allFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'advanced-features' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>All Flags Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(allFlagsApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('All Flags Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(allFlagsApi.isActive).toHaveBeenCalledWith('experimental-features');
|
||||
expect(allFlagsApi.isActive).toHaveBeenCalledWith('advanced-features');
|
||||
});
|
||||
|
||||
it('should support $any feature flag predicates on pages', async () => {
|
||||
const noFlagsApi = createFeatureFlagsApi([]);
|
||||
const noFlagsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => noFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'beta-access' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Any Flag Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const noFlagsRender = await renderWithEffects(noFlagsApp.createRoot());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Any Flag Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
noFlagsRender.unmount();
|
||||
|
||||
const oneFlagApi = createFeatureFlagsApi(['beta-access']);
|
||||
const oneFlagApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => oneFlagApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'beta-access' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Any Flag Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(oneFlagApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Any Flag Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(oneFlagApi.isActive).toHaveBeenCalledWith('experimental-features');
|
||||
expect(oneFlagApi.isActive).toHaveBeenCalledWith('beta-access');
|
||||
});
|
||||
|
||||
it('should support permission predicates on pages', async () => {
|
||||
const deniedPermissionApi = createPermissionApi([]);
|
||||
const deniedApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => deniedPermissionApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Permission Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const deniedRender = await renderWithEffects(deniedApp.createRoot());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Permission Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
deniedRender.unmount();
|
||||
|
||||
const allowedPermissionApi = createPermissionApi(['catalog.entity.create']);
|
||||
const allowedApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => allowedPermissionApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Permission Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(allowedApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Permission Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(allowedPermissionApi.authorize).toHaveBeenCalledWith({
|
||||
permission: {
|
||||
name: 'catalog.entity.create',
|
||||
type: 'basic',
|
||||
attributes: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should support conditional child extensions attached to pages', async () => {
|
||||
const CardBlueprint = createExtensionBlueprint({
|
||||
kind: 'card',
|
||||
attachTo: { id: 'page:test/card-page', input: 'cards' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
*factory(params: { title: string }) {
|
||||
yield coreExtensionData.reactElement(<div>{params.title}</div>);
|
||||
},
|
||||
});
|
||||
|
||||
const page = PageBlueprint.makeWithOverrides({
|
||||
name: 'card-page',
|
||||
inputs: {
|
||||
cards: createExtensionInput([coreExtensionData.reactElement], {
|
||||
optional: false,
|
||||
singleton: false,
|
||||
}),
|
||||
},
|
||||
factory(originalFactory, { inputs }) {
|
||||
return originalFactory({
|
||||
path: '/',
|
||||
loader: async () => (
|
||||
<div>
|
||||
{inputs.cards.map(card =>
|
||||
card.get(coreExtensionData.reactElement),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const publicCard = CardBlueprint.make({
|
||||
name: 'public',
|
||||
params: { title: 'Public Card' },
|
||||
});
|
||||
const permissionCard = CardBlueprint.make({
|
||||
name: 'permission',
|
||||
params: { title: 'Permission Card' },
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
const featureFlagCard = CardBlueprint.make({
|
||||
name: 'feature-flag',
|
||||
params: { title: 'Feature Flag Card' },
|
||||
if: { featureFlags: { $contains: 'experimental-card' } },
|
||||
});
|
||||
|
||||
const hiddenCardsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'permission-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => createPermissionApi([]),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'feature-flags-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => createFeatureFlagsApi([]),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'experimental-card' }],
|
||||
extensions: [page, publicCard, permissionCard, featureFlagCard],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const hiddenCardsRender = await renderWithEffects(
|
||||
hiddenCardsApp.createRoot(),
|
||||
);
|
||||
await expect(screen.findByText('Public Card')).resolves.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Permission Card')).not.toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Feature Flag Card')).not.toBeInTheDocument(),
|
||||
);
|
||||
hiddenCardsRender.unmount();
|
||||
|
||||
const visibleCardsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'permission-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => createPermissionApi(['catalog.entity.create']),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'feature-flags-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => createFeatureFlagsApi(['experimental-card']),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'experimental-card' }],
|
||||
extensions: [page, publicCard, permissionCard, featureFlagCard],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(visibleCardsApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Permission Card'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Feature Flag Card'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should make the app structure available through the AppTreeApi', async () => {
|
||||
let appTreeApi: AppTreeApi | undefined = undefined;
|
||||
|
||||
@@ -430,9 +1092,6 @@ describe('createApp', () => {
|
||||
<app-root-element:app/alert-display out=[core.reactElement] />
|
||||
<app-root-element:app/dialog-display out=[core.reactElement] />
|
||||
]
|
||||
signInPage [
|
||||
<sign-in-page:app />
|
||||
]
|
||||
</app/root>
|
||||
]
|
||||
</app>
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JSX, lazy, ReactNode, Suspense } from 'react';
|
||||
import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react';
|
||||
import {
|
||||
ConfigApi,
|
||||
coreExtensionData,
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
FrontendFeatureLoader,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
@@ -29,8 +29,9 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
CreateAppRouteBinder,
|
||||
createSpecializedApp,
|
||||
ExtensionFactoryMiddleware,
|
||||
FinalizedSpecializedApp,
|
||||
prepareSpecializedApp,
|
||||
PreparedSpecializedApp,
|
||||
FrontendPluginInfoResolver,
|
||||
} from '@backstage/frontend-app-api';
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
@@ -119,23 +120,16 @@ export function createApp(options?: CreateAppOptions): {
|
||||
features: [...discoveredFeaturesAndLoaders, ...(options?.features ?? [])],
|
||||
});
|
||||
|
||||
const app = createSpecializedApp({
|
||||
const preparedApp = prepareSpecializedApp({
|
||||
features: [appPlugin, ...loadedFeatures],
|
||||
config,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
advanced: options?.advanced,
|
||||
});
|
||||
|
||||
const errorPage = maybeCreateErrorPage(app);
|
||||
if (errorPage) {
|
||||
return { default: () => errorPage };
|
||||
}
|
||||
|
||||
const rootEl = app.tree.root.instance!.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
|
||||
return { default: () => rootEl };
|
||||
return {
|
||||
default: () => <PreparedAppRoot preparedApp={preparedApp} />,
|
||||
};
|
||||
}
|
||||
|
||||
const LazyApp = lazy(appLoader);
|
||||
@@ -150,3 +144,28 @@ export function createApp(options?: CreateAppOptions): {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function PreparedAppRoot(props: {
|
||||
preparedApp: PreparedSpecializedApp;
|
||||
}): JSX.Element {
|
||||
const bootstrapApp = props.preparedApp.getBootstrapApp();
|
||||
const [finalizedApp, setFinalizedApp] = useState<
|
||||
FinalizedSpecializedApp | undefined
|
||||
>();
|
||||
|
||||
useEffect(
|
||||
() => props.preparedApp.onFinalized(setFinalizedApp),
|
||||
[props.preparedApp],
|
||||
);
|
||||
|
||||
if (!finalizedApp) {
|
||||
return bootstrapApp.element;
|
||||
}
|
||||
|
||||
const errorPage = maybeCreateErrorPage(finalizedApp);
|
||||
if (errorPage) {
|
||||
return errorPage;
|
||||
}
|
||||
|
||||
return finalizedApp.element;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ const DEFAULT_WARNING_CODES: Array<keyof AppErrorTypes> = [
|
||||
'EXTENSION_INPUT_DATA_IGNORED',
|
||||
'EXTENSION_INPUT_INTERNAL_IGNORED',
|
||||
'EXTENSION_OUTPUT_IGNORED',
|
||||
'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED',
|
||||
'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
|
||||
];
|
||||
|
||||
function AppErrorItem(props: { error: AppError }): JSX.Element {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2024 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 type { ApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
|
||||
export const OpaqueApiRef = OpaqueType.create<{
|
||||
public: ApiRef<unknown> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
versions: {
|
||||
readonly version: 'v1';
|
||||
readonly pluginId?: string;
|
||||
};
|
||||
}>({
|
||||
type: '@backstage/ApiRef',
|
||||
versions: ['v1'],
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2024 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 * from './OpaqueApiRef';
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export * from './routing';
|
||||
export * from './apis';
|
||||
export * from './wiring';
|
||||
|
||||
@@ -24,9 +24,11 @@ import {
|
||||
ExtensionDefinitionParameters,
|
||||
ExtensionInput,
|
||||
PortableSchema,
|
||||
ResolvedExtensionInputs,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
export const OpaqueExtensionDefinition = OpaqueType.create<{
|
||||
public: OverridableExtensionDefinition<ExtensionDefinitionParameters>;
|
||||
@@ -69,6 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
|
||||
readonly name?: string;
|
||||
readonly attachTo: ExtensionDefinitionAttachTo;
|
||||
readonly disabled: boolean;
|
||||
readonly if?: FilterPredicate;
|
||||
readonly configSchema?: PortableSchema<any, any>;
|
||||
readonly inputs: { [inputName in string]: ExtensionInput };
|
||||
readonly output: Array<ExtensionDataRef>;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
IconElement,
|
||||
OverridableFrontendPlugin,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
|
||||
@@ -31,6 +32,7 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{
|
||||
readonly icon?: IconElement;
|
||||
readonly extensions: Extension<unknown>[];
|
||||
readonly featureFlags: FeatureFlagConfig[];
|
||||
readonly if?: FilterPredicate;
|
||||
readonly infoOptions?: {
|
||||
packageJson?: () => Promise<JsonObject>;
|
||||
manifest?: () => Promise<JsonObject>;
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"zod": "^3.25.76",
|
||||
|
||||
@@ -3,13 +3,576 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { ApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { ApiRef as ApiRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ComponentType } from 'react';
|
||||
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ConfigurableExtensionDataRef as ConfigurableExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import { Expand } from '@backstage/types';
|
||||
import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import type { z } from 'zod';
|
||||
|
||||
// @public
|
||||
export type AnyRouteRefParams =
|
||||
| {
|
||||
[param in string]: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// @public
|
||||
export type ApiHolder = {
|
||||
get<T>(api: ApiRef<T>): T | undefined;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ApiRef<T, TId extends string = string> = {
|
||||
readonly $$type?: '@backstage/ApiRef';
|
||||
readonly id: TId;
|
||||
readonly T: T;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface AppNode {
|
||||
readonly edges: AppNodeEdges;
|
||||
readonly instance?: AppNodeInstance;
|
||||
readonly spec: AppNodeSpec;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AppNodeEdges {
|
||||
// (undocumented)
|
||||
readonly attachedTo?: {
|
||||
node: AppNode;
|
||||
input: string;
|
||||
};
|
||||
// (undocumented)
|
||||
readonly attachments: ReadonlyMap<string, AppNode[]>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AppNodeInstance {
|
||||
getData<T>(ref: ExtensionDataRef<T>): T | undefined;
|
||||
getDataRefs(): Iterable<ExtensionDataRef<unknown>>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AppNodeSpec {
|
||||
// (undocumented)
|
||||
readonly attachTo: ExtensionAttachTo;
|
||||
// (undocumented)
|
||||
readonly config?: unknown;
|
||||
// (undocumented)
|
||||
readonly disabled: boolean;
|
||||
// (undocumented)
|
||||
readonly extension: Extension<unknown, unknown>;
|
||||
// (undocumented)
|
||||
readonly id: string;
|
||||
// (undocumented)
|
||||
readonly if?: FilterPredicate;
|
||||
// (undocumented)
|
||||
readonly plugin: FrontendPlugin;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AppTree {
|
||||
readonly nodes: ReadonlyMap<string, AppNode>;
|
||||
readonly orphans: Iterable<AppNode>;
|
||||
readonly root: AppNode;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ConfigurableExtensionDataRef<
|
||||
TData,
|
||||
TId extends string,
|
||||
TConfig extends {
|
||||
optional?: true;
|
||||
} = {},
|
||||
> extends ExtensionDataRef<TData, TId, TConfig> {
|
||||
// (undocumented)
|
||||
(t: TData): ExtensionDataValue<TData, TId>;
|
||||
// (undocumented)
|
||||
optional(): ConfigurableExtensionDataRef<
|
||||
TData,
|
||||
TId,
|
||||
TConfig & {
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function createExtensionBlueprintParams<T extends object = object>(
|
||||
params: T,
|
||||
): ExtensionBlueprintParams<T>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface Extension<TConfig, TConfigInput = TConfig> {
|
||||
// (undocumented)
|
||||
$$type: '@backstage/Extension';
|
||||
// (undocumented)
|
||||
readonly attachTo: ExtensionAttachTo;
|
||||
// (undocumented)
|
||||
readonly configSchema?: PortableSchema<TConfig, TConfigInput>;
|
||||
// (undocumented)
|
||||
readonly disabled: boolean;
|
||||
// (undocumented)
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionAttachTo = {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ExtensionBlueprint<
|
||||
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
|
||||
> {
|
||||
// (undocumented)
|
||||
dataRefs: T['dataRefs'];
|
||||
// (undocumented)
|
||||
make<
|
||||
TName extends string | undefined,
|
||||
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
|
||||
UParentInputs extends ExtensionDataRef,
|
||||
>(args: {
|
||||
name?: TName;
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `<blueprint>.make({ params: defineParams => defineParams(<params>) })`'
|
||||
: T['params'];
|
||||
}): OverridableExtensionDefinition<{
|
||||
kind: T['kind'];
|
||||
name: string | undefined extends TName ? undefined : TName;
|
||||
config: T['config'];
|
||||
configInput: T['configInput'];
|
||||
output: T['output'];
|
||||
inputs: T['inputs'];
|
||||
params: T['params'];
|
||||
}>;
|
||||
makeWithOverrides<
|
||||
TName extends string | undefined,
|
||||
TExtensionConfigSchema extends {
|
||||
[key in string]: (zImpl: typeof z) => z.ZodType;
|
||||
},
|
||||
UFactoryOutput extends ExtensionDataValue<any, any>,
|
||||
UNewOutput extends ExtensionDataRef,
|
||||
UParentInputs extends ExtensionDataRef,
|
||||
TExtraInputs extends {
|
||||
[inputName in string]: ExtensionInput;
|
||||
} = {},
|
||||
>(args: {
|
||||
name?: TName;
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<
|
||||
ExtensionDataRef extends UNewOutput
|
||||
? NonNullable<T['output']>
|
||||
: UNewOutput,
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
};
|
||||
output?: Array<UNewOutput>;
|
||||
config?: {
|
||||
schema: TExtensionConfigSchema & {
|
||||
[KName in keyof T['config']]?: `Error: Config key '${KName &
|
||||
string}' is already defined in parent schema`;
|
||||
};
|
||||
};
|
||||
factory(
|
||||
originalFactory: <
|
||||
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
|
||||
>(
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
|
||||
: T['params'],
|
||||
context?: {
|
||||
config?: T['config'];
|
||||
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
|
||||
},
|
||||
) => ExtensionDataContainer<NonNullable<T['output']>>,
|
||||
context: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder;
|
||||
config: T['config'] & {
|
||||
[key in keyof TExtensionConfigSchema]: z.infer<
|
||||
ReturnType<TExtensionConfigSchema[key]>
|
||||
>;
|
||||
};
|
||||
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
|
||||
},
|
||||
): Iterable<UFactoryOutput> &
|
||||
VerifyExtensionFactoryOutput<
|
||||
ExtensionDataRef extends UNewOutput
|
||||
? NonNullable<T['output']>
|
||||
: UNewOutput,
|
||||
UFactoryOutput
|
||||
>;
|
||||
}): OverridableExtensionDefinition<{
|
||||
config: Expand<
|
||||
(string extends keyof TExtensionConfigSchema
|
||||
? {}
|
||||
: {
|
||||
[key in keyof TExtensionConfigSchema]: z.infer<
|
||||
ReturnType<TExtensionConfigSchema[key]>
|
||||
>;
|
||||
}) &
|
||||
T['config']
|
||||
>;
|
||||
configInput: Expand<
|
||||
(string extends keyof TExtensionConfigSchema
|
||||
? {}
|
||||
: z.input<
|
||||
z.ZodObject<{
|
||||
[key in keyof TExtensionConfigSchema]: ReturnType<
|
||||
TExtensionConfigSchema[key]
|
||||
>;
|
||||
}>
|
||||
>) &
|
||||
T['configInput']
|
||||
>;
|
||||
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
|
||||
inputs: Expand<T['inputs'] & TExtraInputs>;
|
||||
kind: T['kind'];
|
||||
name: string | undefined extends TName ? undefined : TName;
|
||||
params: T['params'];
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ExtensionBlueprintDefineParams<
|
||||
TParams extends object = object,
|
||||
TInput = any,
|
||||
> = (params: TInput) => ExtensionBlueprintParams<TParams>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionBlueprintParameters = {
|
||||
kind: string;
|
||||
params?: object | ExtensionBlueprintDefineParams;
|
||||
configInput?: {
|
||||
[K in string]: any;
|
||||
};
|
||||
config?: {
|
||||
[K in string]: any;
|
||||
};
|
||||
output?: ExtensionDataRef;
|
||||
inputs?: {
|
||||
[KName in string]: ExtensionInput;
|
||||
};
|
||||
dataRefs?: {
|
||||
[name in string]: ExtensionDataRef;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ExtensionBlueprintParams<T extends object = object> = {
|
||||
$$type: '@backstage/BlueprintParams';
|
||||
T: T;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionDataContainer<UExtensionData extends ExtensionDataRef> =
|
||||
Iterable<
|
||||
UExtensionData extends ExtensionDataRef<
|
||||
infer IData,
|
||||
infer IId,
|
||||
infer IConfig
|
||||
>
|
||||
? IConfig['optional'] extends true
|
||||
? never
|
||||
: ExtensionDataValue<IData, IId>
|
||||
: never
|
||||
> & {
|
||||
get<TId extends UExtensionData['id']>(
|
||||
ref: ExtensionDataRef<any, TId, any>,
|
||||
): UExtensionData extends ExtensionDataRef<infer IData, TId, infer IConfig>
|
||||
? IConfig['optional'] extends true
|
||||
? IData | undefined
|
||||
: IData
|
||||
: never;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionDataRef<
|
||||
TData = unknown,
|
||||
TId extends string = string,
|
||||
TConfig extends {
|
||||
optional?: true;
|
||||
} = {
|
||||
optional?: true;
|
||||
},
|
||||
> = {
|
||||
readonly $$type: '@backstage/ExtensionDataRef';
|
||||
readonly id: TId;
|
||||
readonly T: TData;
|
||||
readonly config: TConfig;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionDataValue<TData, TId extends string> = {
|
||||
readonly $$type: '@backstage/ExtensionDataValue';
|
||||
readonly id: TId;
|
||||
readonly value: TData;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ExtensionDefinition<
|
||||
TParams extends ExtensionDefinitionParameters = ExtensionDefinitionParameters,
|
||||
> {
|
||||
// (undocumented)
|
||||
$$type: '@backstage/ExtensionDefinition';
|
||||
// (undocumented)
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ExtensionDefinitionAttachTo<
|
||||
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
|
||||
> =
|
||||
| {
|
||||
id: string;
|
||||
input: string;
|
||||
relative?: never;
|
||||
}
|
||||
| {
|
||||
relative: {
|
||||
kind?: string;
|
||||
name?: string;
|
||||
};
|
||||
input: string;
|
||||
id?: never;
|
||||
}
|
||||
| ExtensionInput<UParentInputs>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionDefinitionParameters = {
|
||||
kind?: string;
|
||||
name?: string;
|
||||
configInput?: {
|
||||
[K in string]: any;
|
||||
};
|
||||
config?: {
|
||||
[K in string]: any;
|
||||
};
|
||||
output?: ExtensionDataRef;
|
||||
inputs?: {
|
||||
[KName in string]: ExtensionInput;
|
||||
};
|
||||
params?: object | ExtensionBlueprintDefineParams;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ExtensionInput<
|
||||
UExtensionData extends ExtensionDataRef<
|
||||
unknown,
|
||||
string,
|
||||
{
|
||||
optional?: true;
|
||||
}
|
||||
> = ExtensionDataRef,
|
||||
TConfig extends {
|
||||
singleton: boolean;
|
||||
optional: boolean;
|
||||
internal?: boolean;
|
||||
} = {
|
||||
singleton: boolean;
|
||||
optional: boolean;
|
||||
internal?: boolean;
|
||||
},
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/ExtensionInput';
|
||||
// (undocumented)
|
||||
readonly config: TConfig;
|
||||
// (undocumented)
|
||||
readonly extensionData: Array<UExtensionData>;
|
||||
// (undocumented)
|
||||
readonly replaces?: Array<{
|
||||
id: string;
|
||||
input: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ExternalRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/ExternalRouteRef';
|
||||
// (undocumented)
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface FrontendPlugin<
|
||||
TRoutes extends {
|
||||
[name in string]: RouteRef | SubRouteRef;
|
||||
} = {
|
||||
[name in string]: RouteRef | SubRouteRef;
|
||||
},
|
||||
TExternalRoutes extends {
|
||||
[name in string]: ExternalRouteRef;
|
||||
} = {
|
||||
[name in string]: ExternalRouteRef;
|
||||
},
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/FrontendPlugin';
|
||||
// (undocumented)
|
||||
readonly externalRoutes: TExternalRoutes;
|
||||
readonly icon?: IconElement;
|
||||
// @deprecated
|
||||
readonly id: string;
|
||||
info(): Promise<FrontendPluginInfo>;
|
||||
readonly pluginId: string;
|
||||
// (undocumented)
|
||||
readonly routes: TRoutes;
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface FrontendPluginInfo {
|
||||
description?: string;
|
||||
links?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
ownerEntityRefs?: string[];
|
||||
packageName?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type IconElement = JSX_2.Element | null;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OverridableExtensionDefinition<
|
||||
T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters,
|
||||
> extends ExtensionDefinition<T> {
|
||||
readonly inputs: {
|
||||
[K in keyof T['inputs']]: ExtensionInput<
|
||||
T['inputs'][K] extends ExtensionInput<infer IData> ? IData : never
|
||||
>;
|
||||
};
|
||||
// (undocumented)
|
||||
override<
|
||||
TExtensionConfigSchema extends {
|
||||
[key in string]: (zImpl: typeof z) => z.ZodType;
|
||||
},
|
||||
UFactoryOutput extends ExtensionDataValue<any, any>,
|
||||
UNewOutput extends ExtensionDataRef,
|
||||
TExtraInputs extends {
|
||||
[inputName in string]: ExtensionInput;
|
||||
},
|
||||
TParamsInput extends AnyParamsInput_2<NonNullable<T['params']>>,
|
||||
UParentInputs extends ExtensionDataRef,
|
||||
>(
|
||||
args: Expand<
|
||||
{
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<
|
||||
ExtensionDataRef extends UNewOutput
|
||||
? NonNullable<T['output']>
|
||||
: UNewOutput,
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
};
|
||||
output?: Array<UNewOutput>;
|
||||
config?: {
|
||||
schema: TExtensionConfigSchema & {
|
||||
[KName in keyof T['config']]?: `Error: Config key '${KName &
|
||||
string}' is already defined in parent schema`;
|
||||
};
|
||||
};
|
||||
factory?(
|
||||
originalFactory: <
|
||||
TFactoryParamsReturn extends AnyParamsInput_2<
|
||||
NonNullable<T['params']>
|
||||
>,
|
||||
>(
|
||||
context?: Expand<
|
||||
{
|
||||
config?: T['config'];
|
||||
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
|
||||
} & ([T['params']] extends [never]
|
||||
? {}
|
||||
: {
|
||||
params?: TFactoryParamsReturn extends ExtensionBlueprintDefineParams
|
||||
? TFactoryParamsReturn
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
|
||||
: Partial<T['params']>;
|
||||
})
|
||||
>,
|
||||
) => ExtensionDataContainer<NonNullable<T['output']>>,
|
||||
context: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder;
|
||||
config: T['config'] & {
|
||||
[key in keyof TExtensionConfigSchema]: z.infer<
|
||||
ReturnType<TExtensionConfigSchema[key]>
|
||||
>;
|
||||
};
|
||||
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
|
||||
},
|
||||
): Iterable<UFactoryOutput>;
|
||||
} & ([T['params']] extends [never]
|
||||
? {}
|
||||
: {
|
||||
params?: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
|
||||
: Partial<T['params']>;
|
||||
})
|
||||
> &
|
||||
VerifyExtensionFactoryOutput<
|
||||
ExtensionDataRef extends UNewOutput
|
||||
? NonNullable<T['output']>
|
||||
: UNewOutput,
|
||||
UFactoryOutput
|
||||
>,
|
||||
): OverridableExtensionDefinition<{
|
||||
kind: T['kind'];
|
||||
name: T['name'];
|
||||
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
|
||||
inputs: T['inputs'] & TExtraInputs;
|
||||
config: T['config'] & {
|
||||
[key in keyof TExtensionConfigSchema]: z.infer<
|
||||
ReturnType<TExtensionConfigSchema[key]>
|
||||
>;
|
||||
};
|
||||
configInput: T['configInput'] &
|
||||
z.input<
|
||||
z.ZodObject<{
|
||||
[key in keyof TExtensionConfigSchema]: ReturnType<
|
||||
TExtensionConfigSchema[key]
|
||||
>;
|
||||
}>
|
||||
>;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type PluginWrapperApi = {
|
||||
@@ -24,17 +587,22 @@ export type PluginWrapperApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const pluginWrapperApiRef: ApiRef<PluginWrapperApi>;
|
||||
export const pluginWrapperApiRef: ApiRef_2<
|
||||
PluginWrapperApi,
|
||||
'core.plugin-wrapper'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export const PluginWrapperBlueprint: ExtensionBlueprint<{
|
||||
export const PluginWrapperBlueprint: ExtensionBlueprint_2<{
|
||||
kind: 'plugin-wrapper';
|
||||
params: <TValue = never>(params: {
|
||||
loader: () => Promise<PluginWrapperDefinition<TValue>>;
|
||||
}) => ExtensionBlueprintParams<{
|
||||
}) => ExtensionBlueprintParams_2<{
|
||||
loader: () => Promise<PluginWrapperDefinition>;
|
||||
}>;
|
||||
output: ExtensionDataRef<
|
||||
output: ExtensionDataRef_2<
|
||||
() => Promise<PluginWrapperDefinition>,
|
||||
'core.plugin-wrapper.loader',
|
||||
{}
|
||||
@@ -43,7 +611,7 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{
|
||||
config: {};
|
||||
configInput: {};
|
||||
dataRefs: {
|
||||
wrapper: ConfigurableExtensionDataRef<
|
||||
wrapper: ConfigurableExtensionDataRef_2<
|
||||
() => Promise<PluginWrapperDefinition>,
|
||||
'core.plugin-wrapper.loader',
|
||||
{}
|
||||
@@ -60,5 +628,33 @@ export type PluginWrapperDefinition<TValue = unknown | never> = {
|
||||
}>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type PortableSchema<TOutput, TInput = TOutput> = {
|
||||
parse: (input: TInput) => TOutput;
|
||||
schema: JsonObject;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface RouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/RouteRef';
|
||||
// (undocumented)
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface SubRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/SubRouteRef';
|
||||
// (undocumented)
|
||||
readonly path: string;
|
||||
// (undocumented)
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-
|
||||
import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionInput as ExtensionInput_2 } from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
@@ -30,8 +31,10 @@ export type AlertApi = {
|
||||
alert$(): Observable<AlertMessage>;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export const alertApiRef: ApiRef<AlertApi>;
|
||||
// @public
|
||||
export const alertApiRef: ApiRef_2<AlertApi, 'core.alert'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export type AlertMessage = {
|
||||
@@ -46,7 +49,9 @@ export type AnalyticsApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const analyticsApiRef: ApiRef<AnalyticsApi>;
|
||||
export const analyticsApiRef: ApiRef_2<AnalyticsApi, 'core.analytics'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export const AnalyticsContext: (options: {
|
||||
@@ -187,9 +192,10 @@ export type ApiHolder = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ApiRef<T> = {
|
||||
id: string;
|
||||
T: T;
|
||||
export type ApiRef<T, TId extends string = string> = {
|
||||
readonly $$type?: '@backstage/ApiRef';
|
||||
readonly id: TId;
|
||||
readonly T: T;
|
||||
};
|
||||
|
||||
// @public
|
||||
@@ -212,7 +218,9 @@ export type AppLanguageApi = {
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const appLanguageApiRef: ApiRef<AppLanguageApi>;
|
||||
export const appLanguageApiRef: ApiRef_2<AppLanguageApi, 'core.applanguage'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface AppNode {
|
||||
@@ -251,6 +259,8 @@ export interface AppNodeSpec {
|
||||
// (undocumented)
|
||||
readonly id: string;
|
||||
// (undocumented)
|
||||
readonly if?: FilterPredicate;
|
||||
// (undocumented)
|
||||
readonly plugin: FrontendPlugin;
|
||||
}
|
||||
|
||||
@@ -285,7 +295,9 @@ export type AppThemeApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const appThemeApiRef: ApiRef<AppThemeApi>;
|
||||
export const appThemeApiRef: ApiRef_2<AppThemeApi, 'core.apptheme'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface AppTree {
|
||||
@@ -305,12 +317,17 @@ export interface AppTreeApi {
|
||||
}
|
||||
|
||||
// @public
|
||||
export const appTreeApiRef: ApiRef_2<AppTreeApi>;
|
||||
export const appTreeApiRef: ApiRef_2<AppTreeApi, 'core.app-tree'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export const atlassianAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
>;
|
||||
export const atlassianAuthApiRef: ApiRef_2<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
|
||||
'core.auth.atlassian'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AuthProviderInfo = {
|
||||
@@ -348,20 +365,28 @@ export type BackstageUserIdentity = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const bitbucketAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
>;
|
||||
export const bitbucketAuthApiRef: ApiRef_2<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
|
||||
'core.auth.bitbucket'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export const bitbucketServerAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
>;
|
||||
export const bitbucketServerAuthApiRef: ApiRef_2<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
|
||||
'core.auth.bitbucket-server'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ConfigApi = Config;
|
||||
|
||||
// @public
|
||||
export const configApiRef: ApiRef<ConfigApi>;
|
||||
export const configApiRef: ApiRef_2<Config, 'core.config'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ConfigurableExtensionDataRef<
|
||||
@@ -415,8 +440,22 @@ export function createApiFactory<Api, Impl extends Api>(
|
||||
instance: Impl,
|
||||
): ApiFactory<Api, Impl, {}>;
|
||||
|
||||
// @public @deprecated
|
||||
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
|
||||
export function createApiRef<T>(): {
|
||||
with<const TId extends string>(
|
||||
config: ApiRefConfig & {
|
||||
id: TId;
|
||||
pluginId?: string;
|
||||
},
|
||||
): ApiRef<T, TId> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export function createExtension<
|
||||
@@ -541,6 +580,7 @@ export type CreateExtensionBlueprintOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -627,6 +667,7 @@ export type CreateExtensionOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -715,6 +756,8 @@ export interface CreateFrontendModuleOptions<
|
||||
// (undocumented)
|
||||
featureFlags?: FeatureFlagConfig[];
|
||||
// (undocumented)
|
||||
if?: FilterPredicate;
|
||||
// (undocumented)
|
||||
pluginId: TPluginId;
|
||||
}
|
||||
|
||||
@@ -729,13 +772,47 @@ export function createFrontendPlugin<
|
||||
[name in string]: ExternalRouteRef;
|
||||
} = {},
|
||||
>(
|
||||
options: PluginOptions<TId, TRoutes, TExternalRoutes, TExtensions>,
|
||||
options: CreateFrontendPluginOptions<
|
||||
TId,
|
||||
TRoutes,
|
||||
TExternalRoutes,
|
||||
TExtensions
|
||||
>,
|
||||
): OverridableFrontendPlugin<
|
||||
TRoutes,
|
||||
TExternalRoutes,
|
||||
MakeSortedExtensionsMap<TExtensions[number], TId>
|
||||
>;
|
||||
|
||||
// @public
|
||||
export interface CreateFrontendPluginOptions<
|
||||
TId extends string,
|
||||
TRoutes extends {
|
||||
[name in string]: RouteRef | SubRouteRef;
|
||||
},
|
||||
TExternalRoutes extends {
|
||||
[name in string]: ExternalRouteRef;
|
||||
},
|
||||
TExtensions extends readonly ExtensionDefinition[],
|
||||
> {
|
||||
// (undocumented)
|
||||
extensions?: TExtensions;
|
||||
// (undocumented)
|
||||
externalRoutes?: TExternalRoutes;
|
||||
// (undocumented)
|
||||
featureFlags?: FeatureFlagConfig[];
|
||||
icon?: IconElement;
|
||||
// (undocumented)
|
||||
if?: FilterPredicate;
|
||||
// (undocumented)
|
||||
info?: FrontendPluginInfoOptions;
|
||||
// (undocumented)
|
||||
pluginId: TId;
|
||||
// (undocumented)
|
||||
routes?: TRoutes;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function createRouteRef<
|
||||
TParams extends
|
||||
@@ -869,7 +946,9 @@ export interface DialogApiDialog<TResult = void> {
|
||||
}
|
||||
|
||||
// @public
|
||||
export const dialogApiRef: ApiRef_2<DialogApi>;
|
||||
export const dialogApiRef: ApiRef_2<DialogApi, 'core.dialog'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type DiscoveryApi = {
|
||||
@@ -877,7 +956,9 @@ export type DiscoveryApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const discoveryApiRef: ApiRef<DiscoveryApi>;
|
||||
export const discoveryApiRef: ApiRef_2<DiscoveryApi, 'core.discovery'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ErrorApi = {
|
||||
@@ -901,7 +982,9 @@ export type ErrorApiErrorContext = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const errorApiRef: ApiRef<ErrorApi>;
|
||||
export const errorApiRef: ApiRef_2<ErrorApi, 'core.error'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const ErrorDisplay: {
|
||||
@@ -952,6 +1035,7 @@ export interface ExtensionBlueprint<
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
@@ -987,6 +1071,7 @@ export interface ExtensionBlueprint<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -1285,7 +1370,12 @@ export interface FeatureFlagsApi {
|
||||
}
|
||||
|
||||
// @public
|
||||
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi>;
|
||||
export const featureFlagsApiRef: ApiRef_2<
|
||||
FeatureFlagsApi,
|
||||
'core.featureflags'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type FeatureFlagsSaveOptions = {
|
||||
@@ -1317,7 +1407,9 @@ export type FetchApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const fetchApiRef: ApiRef<FetchApi>;
|
||||
export const fetchApiRef: ApiRef_2<FetchApi, 'core.fetch'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type FrontendFeature =
|
||||
@@ -1390,27 +1482,36 @@ export type FrontendPluginInfoOptions = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const githubAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
>;
|
||||
export const githubAuthApiRef: ApiRef_2<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
|
||||
'core.auth.github'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export const gitlabAuthApiRef: ApiRef<
|
||||
export const gitlabAuthApiRef: ApiRef_2<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
>;
|
||||
SessionApi,
|
||||
'core.auth.gitlab'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export const googleAuthApiRef: ApiRef<
|
||||
export const googleAuthApiRef: ApiRef_2<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
>;
|
||||
SessionApi,
|
||||
'core.auth.google'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export type IconComponent = ComponentType<{
|
||||
@@ -1430,7 +1531,9 @@ export interface IconsApi {
|
||||
}
|
||||
|
||||
// @public
|
||||
export const iconsApiRef: ApiRef_2<IconsApi>;
|
||||
export const iconsApiRef: ApiRef_2<IconsApi, 'core.icons'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type IdentityApi = {
|
||||
@@ -1443,16 +1546,21 @@ export type IdentityApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const identityApiRef: ApiRef<IdentityApi>;
|
||||
export const identityApiRef: ApiRef_2<IdentityApi, 'core.identity'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export const microsoftAuthApiRef: ApiRef<
|
||||
export const microsoftAuthApiRef: ApiRef_2<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
>;
|
||||
SessionApi,
|
||||
'core.auth.microsoft'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export const NavItemBlueprint: ExtensionBlueprint_2<{
|
||||
@@ -1515,7 +1623,12 @@ export type OAuthRequestApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const oauthRequestApiRef: ApiRef<OAuthRequestApi>;
|
||||
export const oauthRequestApiRef: ApiRef_2<
|
||||
OAuthRequestApi,
|
||||
'core.oauthrequest'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type OAuthRequester<TAuthResponse> = (
|
||||
@@ -1532,22 +1645,28 @@ export type OAuthRequesterOptions<TOAuthResponse> = {
|
||||
export type OAuthScope = string | string[];
|
||||
|
||||
// @public
|
||||
export const oktaAuthApiRef: ApiRef<
|
||||
export const oktaAuthApiRef: ApiRef_2<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
>;
|
||||
SessionApi,
|
||||
'core.auth.okta'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export const oneloginAuthApiRef: ApiRef<
|
||||
export const oneloginAuthApiRef: ApiRef_2<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
>;
|
||||
SessionApi,
|
||||
'core.auth.onelogin'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type OpenIdConnectApi = {
|
||||
@@ -1555,9 +1674,12 @@ export type OpenIdConnectApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const openshiftAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
>;
|
||||
export const openshiftAuthApiRef: ApiRef_2<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
|
||||
'core.auth.openshift'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OverridableExtensionDefinition<
|
||||
@@ -1591,6 +1713,7 @@ export interface OverridableExtensionDefinition<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -1696,6 +1819,7 @@ export interface OverridableFrontendPlugin<
|
||||
// (undocumented)
|
||||
withOverrides(options: {
|
||||
extensions?: Array<ExtensionDefinition>;
|
||||
if?: FilterPredicate;
|
||||
title?: string;
|
||||
icon?: IconElement;
|
||||
info?: FrontendPluginInfoOptions;
|
||||
@@ -1845,10 +1969,15 @@ export type PluginHeaderActionsApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const pluginHeaderActionsApiRef: ApiRef_2<PluginHeaderActionsApi>;
|
||||
export const pluginHeaderActionsApiRef: ApiRef_2<
|
||||
PluginHeaderActionsApi,
|
||||
'core.plugin-header-actions'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface PluginOptions<
|
||||
// @public @deprecated (undocumented)
|
||||
export type PluginOptions<
|
||||
TId extends string,
|
||||
TRoutes extends {
|
||||
[name in string]: RouteRef | SubRouteRef;
|
||||
@@ -1857,22 +1986,7 @@ export interface PluginOptions<
|
||||
[name in string]: ExternalRouteRef;
|
||||
},
|
||||
TExtensions extends readonly ExtensionDefinition[],
|
||||
> {
|
||||
// (undocumented)
|
||||
extensions?: TExtensions;
|
||||
// (undocumented)
|
||||
externalRoutes?: TExternalRoutes;
|
||||
// (undocumented)
|
||||
featureFlags?: FeatureFlagConfig[];
|
||||
icon?: IconElement;
|
||||
// (undocumented)
|
||||
info?: FrontendPluginInfoOptions;
|
||||
// (undocumented)
|
||||
pluginId: TId;
|
||||
// (undocumented)
|
||||
routes?: TRoutes;
|
||||
title?: string;
|
||||
}
|
||||
> = CreateFrontendPluginOptions<TId, TRoutes, TExternalRoutes, TExtensions>;
|
||||
|
||||
// @public
|
||||
export type PluginWrapperApi = {
|
||||
@@ -1887,7 +2001,12 @@ export type PluginWrapperApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const pluginWrapperApiRef: ApiRef_2<PluginWrapperApi>;
|
||||
export const pluginWrapperApiRef: ApiRef_2<
|
||||
PluginWrapperApi,
|
||||
'core.plugin-wrapper'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export const PluginWrapperBlueprint: ExtensionBlueprint_2<{
|
||||
@@ -1950,19 +2069,6 @@ export const Progress: {
|
||||
// @public (undocumented)
|
||||
export type ProgressProps = {};
|
||||
|
||||
// @public
|
||||
export type ResolvedExtensionInputs<
|
||||
TInputs extends {
|
||||
[name in string]: ExtensionInput;
|
||||
},
|
||||
> = {
|
||||
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
|
||||
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]>>>
|
||||
: false extends TInputs[InputName]['config']['optional']
|
||||
? Expand<ResolvedExtensionInput<TInputs[InputName]>>
|
||||
: Expand<ResolvedExtensionInput<TInputs[InputName]> | undefined>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type RouteFunc<TParams extends AnyRouteRefParams> = (
|
||||
...input: TParams extends undefined ? readonly [] : readonly [params: TParams]
|
||||
@@ -1993,7 +2099,12 @@ export interface RouteResolutionApi {
|
||||
}
|
||||
|
||||
// @public
|
||||
export const routeResolutionApiRef: ApiRef_2<RouteResolutionApi>;
|
||||
export const routeResolutionApiRef: ApiRef_2<
|
||||
RouteResolutionApi,
|
||||
'core.route-resolution'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SessionApi = {
|
||||
@@ -2031,7 +2142,9 @@ export interface StorageApi {
|
||||
}
|
||||
|
||||
// @public
|
||||
export const storageApiRef: ApiRef<StorageApi>;
|
||||
export const storageApiRef: ApiRef_2<StorageApi, 'core.storage'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type StorageValueSnapshot<TValue extends JsonValue> =
|
||||
@@ -2121,7 +2234,12 @@ export interface SwappableComponentsApi {
|
||||
}
|
||||
|
||||
// @public
|
||||
export const swappableComponentsApiRef: ApiRef_2<SwappableComponentsApi>;
|
||||
export const swappableComponentsApiRef: ApiRef_2<
|
||||
SwappableComponentsApi,
|
||||
'core.swappable-components'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ToastApi = {
|
||||
@@ -2170,7 +2288,9 @@ export type TranslationApi = {
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const translationApiRef: ApiRef<TranslationApi>;
|
||||
export const translationApiRef: ApiRef_2<TranslationApi, 'core.translation'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type TranslationFunction<
|
||||
@@ -2360,13 +2480,16 @@ export const useTranslationRef: <TMessages extends { [key in string]: string }>(
|
||||
};
|
||||
|
||||
// @public
|
||||
export const vmwareCloudAuthApiRef: ApiRef<
|
||||
export const vmwareCloudAuthApiRef: ApiRef_2<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
>;
|
||||
SessionApi,
|
||||
'core.auth.vmware-cloud'
|
||||
> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export function withApis<T extends {}>(
|
||||
|
||||
@@ -20,6 +20,43 @@ export {
|
||||
PluginWrapperBlueprint,
|
||||
type PluginWrapperDefinition,
|
||||
} from './blueprints/PluginWrapperBlueprint';
|
||||
export type {
|
||||
ConfigurableExtensionDataRef,
|
||||
Extension,
|
||||
ExtensionAttachTo,
|
||||
ExtensionDefinition,
|
||||
ExtensionDefinitionParameters,
|
||||
ExtensionBlueprintDefineParams,
|
||||
ExtensionBlueprint,
|
||||
ExtensionBlueprintParameters,
|
||||
ExtensionBlueprintParams,
|
||||
ExtensionDataContainer,
|
||||
ExtensionDataRef,
|
||||
ExtensionDataValue,
|
||||
ExtensionDefinitionAttachTo,
|
||||
ExtensionInput,
|
||||
FrontendPlugin,
|
||||
OverridableExtensionDefinition,
|
||||
} from './wiring';
|
||||
export type {
|
||||
ApiHolder,
|
||||
ApiRef,
|
||||
AppNode,
|
||||
AppNodeEdges,
|
||||
AppNodeInstance,
|
||||
AppNodeSpec,
|
||||
AppTree,
|
||||
} from './apis';
|
||||
export type { PortableSchema } from './schema';
|
||||
export type {
|
||||
AnyRouteRefParams,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
ExternalRouteRef,
|
||||
} from './routing';
|
||||
export type { IconElement } from './icons';
|
||||
export type { FrontendPluginInfo } from './wiring';
|
||||
export { createExtensionBlueprintParams } from './wiring';
|
||||
export {
|
||||
type PluginWrapperApi,
|
||||
pluginWrapperApiRef,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef, ApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
@@ -77,6 +77,7 @@ export type AlertApi = {
|
||||
* @public
|
||||
* @deprecated Use {@link toastApiRef} instead. AlertApi will be removed in a future release.
|
||||
*/
|
||||
export const alertApiRef: ApiRef<AlertApi> = createApiRef({
|
||||
export const alertApiRef = createApiRef<AlertApi>().with({
|
||||
id: 'core.alert',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
import { AnalyticsContextValue } from '../../analytics/types';
|
||||
|
||||
/**
|
||||
@@ -151,6 +151,7 @@ export type AnalyticsApi = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const analyticsApiRef: ApiRef<AnalyticsApi> = createApiRef({
|
||||
export const analyticsApiRef = createApiRef<AnalyticsApi>().with({
|
||||
id: 'core.analytics',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/** @public */
|
||||
@@ -31,6 +31,7 @@ export type AppLanguageApi = {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const appLanguageApiRef: ApiRef<AppLanguageApi> = createApiRef({
|
||||
export const appLanguageApiRef = createApiRef<AppLanguageApi>().with({
|
||||
id: 'core.applanguage',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
@@ -82,6 +82,7 @@ export type AppThemeApi = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const appThemeApiRef: ApiRef<AppThemeApi> = createApiRef({
|
||||
export const appThemeApiRef = createApiRef<AppThemeApi>().with({
|
||||
id: 'core.apptheme',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { createApiRef } from '../system';
|
||||
import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
|
||||
import { ExtensionAttachTo } from '../../wiring/resolveExtensionDefinition';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* The specification for this {@link AppNode} in the {@link AppTree}.
|
||||
@@ -32,6 +33,7 @@ export interface AppNodeSpec {
|
||||
readonly attachTo: ExtensionAttachTo;
|
||||
readonly extension: Extension<unknown, unknown>;
|
||||
readonly disabled: boolean;
|
||||
readonly if?: FilterPredicate;
|
||||
readonly config?: unknown;
|
||||
readonly plugin: FrontendPlugin;
|
||||
}
|
||||
@@ -117,4 +119,7 @@ export interface AppTreeApi {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const appTreeApiRef = createApiRef<AppTreeApi>({ id: 'core.app-tree' });
|
||||
export const appTreeApiRef = createApiRef<AppTreeApi>().with({
|
||||
id: 'core.app-tree',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
import type { Config } from '@backstage/config';
|
||||
|
||||
/**
|
||||
@@ -29,6 +29,7 @@ export type ConfigApi = Config;
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const configApiRef: ApiRef<ConfigApi> = createApiRef({
|
||||
export const configApiRef = createApiRef<ConfigApi>().with({
|
||||
id: 'core.config',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -173,6 +173,7 @@ export interface DialogApi {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const dialogApiRef = createApiRef<DialogApi>({
|
||||
export const dialogApiRef = createApiRef<DialogApi>().with({
|
||||
id: 'core.dialog',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* The discovery API is used to provide a mechanism for plugins to
|
||||
@@ -50,6 +50,7 @@ export type DiscoveryApi = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const discoveryApiRef: ApiRef<DiscoveryApi> = createApiRef({
|
||||
export const discoveryApiRef = createApiRef<DiscoveryApi>().with({
|
||||
id: 'core.discovery',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
@@ -86,6 +86,7 @@ export type ErrorApi = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const errorApiRef: ApiRef<ErrorApi> = createApiRef({
|
||||
export const errorApiRef = createApiRef<ErrorApi>().with({
|
||||
id: 'core.error',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
/* We want to maintain the same information as an enum, so we disable the redeclaration warning */
|
||||
/* eslint-disable @typescript-eslint/no-redeclare */
|
||||
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* Feature flag descriptor.
|
||||
@@ -121,6 +121,7 @@ export interface FeatureFlagsApi {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi> = createApiRef({
|
||||
export const featureFlagsApiRef = createApiRef<FeatureFlagsApi>().with({
|
||||
id: 'core.featureflags',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* A wrapper for the fetch API, that has additional behaviors such as the
|
||||
@@ -46,6 +46,7 @@ export type FetchApi = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const fetchApiRef: ApiRef<FetchApi> = createApiRef({
|
||||
export const fetchApiRef = createApiRef<FetchApi>().with({
|
||||
id: 'core.fetch',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface IconsApi {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const iconsApiRef = createApiRef<IconsApi>({
|
||||
export const iconsApiRef = createApiRef<IconsApi>().with({
|
||||
id: 'core.icons',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
import { BackstageUserIdentity, ProfileInfo } from './auth';
|
||||
|
||||
/**
|
||||
@@ -51,6 +51,7 @@ export type IdentityApi = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const identityApiRef: ApiRef<IdentityApi> = createApiRef({
|
||||
export const identityApiRef = createApiRef<IdentityApi>().with({
|
||||
id: 'core.identity',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Observable } from '@backstage/types';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { createApiRef } from '../system';
|
||||
import { AuthProviderInfo } from './auth';
|
||||
|
||||
/**
|
||||
@@ -126,6 +126,7 @@ export type OAuthRequestApi = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const oauthRequestApiRef: ApiRef<OAuthRequestApi> = createApiRef({
|
||||
export const oauthRequestApiRef = createApiRef<OAuthRequestApi>().with({
|
||||
id: 'core.oauthrequest',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
@@ -40,6 +40,8 @@ export type PluginHeaderActionsApi = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const pluginHeaderActionsApiRef = createApiRef<PluginHeaderActionsApi>({
|
||||
id: 'core.plugin-header-actions',
|
||||
});
|
||||
export const pluginHeaderActionsApiRef =
|
||||
createApiRef<PluginHeaderActionsApi>().with({
|
||||
id: 'core.plugin-header-actions',
|
||||
pluginId: 'app',
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user