diff --git a/.changeset/add-actions-cli-module.md b/.changeset/add-actions-cli-module.md new file mode 100644 index 0000000000..538509547f --- /dev/null +++ b/.changeset/add-actions-cli-module.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-actions': patch +--- + +Added `actions` CLI module for listing and executing actions from the distributed actions registry. Includes `actions list`, `actions execute`, and `actions sources` commands for managing plugin sources. diff --git a/.changeset/add-cli-module-template-support.md b/.changeset/add-cli-module-template-support.md new file mode 100644 index 0000000000..be965d882d --- /dev/null +++ b/.changeset/add-cli-module-template-support.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-new': patch +--- + +Added support for the `cli-module` template role for scaffolding new CLI module packages. diff --git a/.changeset/add-cli-module-template.md b/.changeset/add-cli-module-template.md new file mode 100644 index 0000000000..f6b169906f --- /dev/null +++ b/.changeset/add-cli-module-template.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added a new `cli-module` template for creating CLI module packages. diff --git a/.changeset/allow-user-action-invocation.md b/.changeset/allow-user-action-invocation.md new file mode 100644 index 0000000000..d9ac46a60e --- /dev/null +++ b/.changeset/allow-user-action-invocation.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': minor +--- + +The actions registry invoke endpoint now accepts direct user credentials in addition to service principals, enabling CLI and other direct user clients to invoke actions. diff --git a/.changeset/api-ref-plugin-owner-app.md b/.changeset/api-ref-plugin-owner-app.md new file mode 100644 index 0000000000..e9727a3255 --- /dev/null +++ b/.changeset/api-ref-plugin-owner-app.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Frontend apps now respect an explicit `pluginId` on `ApiRef`s when deciding which plugin owns an API factory. diff --git a/.changeset/api-ref-plugin-owner-core.md b/.changeset/api-ref-plugin-owner-core.md new file mode 100644 index 0000000000..005dc06424 --- /dev/null +++ b/.changeset/api-ref-plugin-owner-core.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Updated `createApiRef` to preserve the direct config call without deprecation warnings while staying compatible with the new frontend API ref typing. diff --git a/.changeset/auth-module-exports.md b/.changeset/auth-module-exports.md new file mode 100644 index 0000000000..0b6dc46ac3 --- /dev/null +++ b/.changeset/auth-module-exports.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-auth': patch +--- + +Export auth helper utilities for use by other CLI modules. Added per-instance config storage with `getInstanceConfig` and `updateInstanceConfig`. diff --git a/.changeset/cli-defaults-actions.md b/.changeset/cli-defaults-actions.md new file mode 100644 index 0000000000..1c46f96ead --- /dev/null +++ b/.changeset/cli-defaults-actions.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-defaults': patch +--- + +Added `@backstage/cli-module-actions` to the default set of CLI modules. diff --git a/.changeset/core-compat-api-filter-predicates-dependency.md b/.changeset/core-compat-api-filter-predicates-dependency.md new file mode 100644 index 0000000000..5b99e9c79c --- /dev/null +++ b/.changeset/core-compat-api-filter-predicates-dependency.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-compat-api': patch +--- + +Added a missing dependency on `@backstage/filter-predicates` to `@backstage/core-compat-api`. This fixes package metadata for consumers that use compatibility helpers relying on filter predicate support. diff --git a/.changeset/core-components-progress-accessibility.md b/.changeset/core-components-progress-accessibility.md new file mode 100644 index 0000000000..c4f53ccd2d --- /dev/null +++ b/.changeset/core-components-progress-accessibility.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fixed the shared `Progress` component to provide an accessible name for its loading indicator by default. diff --git a/.changeset/fix-api-review-frontend-app-api.md b/.changeset/fix-api-review-frontend-app-api.md new file mode 100644 index 0000000000..ff287ac2a5 --- /dev/null +++ b/.changeset/fix-api-review-frontend-app-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Removed `@backstage/core-plugin-api` leakage from the public API surface. All types such as `ApiHolder` and `ConfigApi` are now imported from `@backstage/frontend-plugin-api`. diff --git a/.changeset/fix-api-review-frontend-plugin-api.md b/.changeset/fix-api-review-frontend-plugin-api.md new file mode 100644 index 0000000000..10458d8ebb --- /dev/null +++ b/.changeset/fix-api-review-frontend-plugin-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +Renamed `PluginOptions` to `CreateFrontendPluginOptions` and deprecated the old name. Removed `ResolvedExtensionInputs` from the main entry point; it is still available as an inline type in extension factory signatures. diff --git a/.changeset/fix-checkbox-empty-label-gap.md b/.changeset/fix-checkbox-empty-label-gap.md new file mode 100644 index 0000000000..323d8e7bab --- /dev/null +++ b/.changeset/fix-checkbox-empty-label-gap.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Made Checkbox `children` optional and added a dev warning when neither a visible label, `aria-label`, nor `aria-labelledby` is provided. The label wrapper div is no longer rendered when there are no children, removing the unnecessary gap. + +**Affected components:** Checkbox diff --git a/.changeset/fix-table-row-bg-consumer.md b/.changeset/fix-table-row-bg-consumer.md new file mode 100644 index 0000000000..3c8d64198c --- /dev/null +++ b/.changeset/fix-table-row-bg-consumer.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed Table row hover, selected, pressed, and disabled background states to use the correct neutral token level based on the container background. + +**Affected components:** Table diff --git a/.changeset/fix-table-row-hover-selection.md b/.changeset/fix-table-row-hover-selection.md new file mode 100644 index 0000000000..2543b22389 --- /dev/null +++ b/.changeset/fix-table-row-hover-selection.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Updated Table selection checkboxes to use `aria-label` instead of empty fragment children, improving accessibility and removing the unnecessary label gap in the selection cells. + +**Affected components:** Table diff --git a/.changeset/frontend-test-utils-filter-predicates-dependency.md b/.changeset/frontend-test-utils-filter-predicates-dependency.md new file mode 100644 index 0000000000..c8664d9673 --- /dev/null +++ b/.changeset/frontend-test-utils-filter-predicates-dependency.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +Added a missing dependency on `@backstage/filter-predicates` to `@backstage/frontend-test-utils`. This fixes package metadata for consumers using the frontend test app helpers with predicate-based behavior. diff --git a/.changeset/opaque-api-ref-type.md b/.changeset/opaque-api-ref-type.md new file mode 100644 index 0000000000..6ae300f556 --- /dev/null +++ b/.changeset/opaque-api-ref-type.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`. The builder form now also preserves literal API ref IDs in the resulting `ApiRef` type. + +The `createApiRef().with({ ... })` form can also use an explicit `pluginId` to declare API ownership without encoding the plugin ID into the API ref ID, while keeping that metadata internal to runtime handling. diff --git a/.changeset/permission-api-batching.md b/.changeset/permission-api-batching.md new file mode 100644 index 0000000000..187eb2a3bc --- /dev/null +++ b/.changeset/permission-api-batching.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-react': patch +--- + +Permission checks made in the same tick are now batched into a single call to the permission backend. diff --git a/.changeset/plugin-app-bootstrap-phase.md b/.changeset/plugin-app-bootstrap-phase.md new file mode 100644 index 0000000000..c3bab9e563 --- /dev/null +++ b/.changeset/plugin-app-bootstrap-phase.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Updated the default app root to better support phased app preparation by allowing the app layout to be absent during bootstrap, routing bootstrap failures through the app root boundary, and avoiding installation of a guest identity in protected apps that do not provide a sign-in page. diff --git a/.changeset/prepare-specialized-app-frontend-app-api.md b/.changeset/prepare-specialized-app-frontend-app-api.md new file mode 100644 index 0000000000..e62d025189 --- /dev/null +++ b/.changeset/prepare-specialized-app-frontend-app-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Added `prepareSpecializedApp` for two-phase app wiring so apps can render a bootstrap tree before full app finalization. The bootstrap phase now supports deferred `app/root.elements`, predicate-gated APIs, reusable `sessionState`, and warnings for bootstrap-visible predicates or bootstrap code that accessed APIs that only became available after finalization. Utility APIs that are materialized during bootstrap are also frozen for the lifetime of the app instance, causing deferred overrides of those APIs to be ignored and reported as app errors. diff --git a/.changeset/prepare-specialized-app-frontend-defaults.md b/.changeset/prepare-specialized-app-frontend-defaults.md new file mode 100644 index 0000000000..12e153fc75 --- /dev/null +++ b/.changeset/prepare-specialized-app-frontend-defaults.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': patch +--- + +Updated `createApp` to use the phased `prepareSpecializedApp` flow, allowing apps to render a bootstrap tree before the full app is finalized. diff --git a/.changeset/prepare-specialized-app-frontend-plugin-api.md b/.changeset/prepare-specialized-app-frontend-plugin-api.md new file mode 100644 index 0000000000..9933690f39 --- /dev/null +++ b/.changeset/prepare-specialized-app-frontend-plugin-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added support for `if` predicates on `createFrontendPlugin` and `createFrontendModule`, applying shared conditions to every extension in the feature. Plugin and extension overrides can now also replace or remove existing `if` predicates. diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index 22457bcb30..3e7c760a42 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -48,6 +48,52 @@ Because feature discovery needs to interact with the compilation process, it is For information on how to configure feature discovery and other installation options, see [Installing Plugins](../building-apps/05-installing-plugins.md). +## Preparing an App in Phases + +Most apps should use `createApp` from `@backstage/frontend-defaults`, which takes care of all app preparation internally. For more advanced use cases there is also a lower-level `prepareSpecializedApp` API in `@backstage/frontend-app-api`. + +This API is useful when you need to render a bootstrap tree before the full app can be finalized, for example while waiting for sign-in or other session-dependent state. It gives you access to a bootstrap app tree immediately, lets you either subscribe to finalization with `onFinalized()` or finalize synchronously with `finalize()`, and lets you reuse a prepared session in a later app instance. + +```tsx +import { + FinalizedSpecializedApp, + prepareSpecializedApp, +} from '@backstage/frontend-app-api'; + +const preparedApp = prepareSpecializedApp({ + config, + features: [appPlugin, ...features], +}); + +const bootstrapApp = preparedApp.getBootstrapApp(); + +const unsubscribe = preparedApp.onFinalized( + (finalizedApp: FinalizedSpecializedApp) => { + console.log(finalizedApp.sessionState); + }, +); +``` + +The `getBootstrapApp()` method exposes the partial app tree that is available during bootstrap. If you call `onFinalized()`, you are subscribing to the bootstrap-owned finalization flow. In the sign-in case, the sign-in page receives an `onSignInSuccess` callback, and once it provides an identity through that callback the full app is finalized and `onFinalized()` subscribers are notified. + +If you instead call `finalize()`, you are taking ownership of finalization yourself. This only works when the app can be finalized synchronously, for example when all predicate context is already available or when you passed a reusable session state to `prepareSpecializedApp()` up front: + +```tsx +const preparedApp = prepareSpecializedApp({ + config, + features: [appPlugin, ...features], + advanced: { + sessionState, + }, +}); + +const app = preparedApp.finalize(); +``` + +When using phased app preparation, `app/root.children` acts as the main session boundary. Conditional extensions behind that boundary are evaluated during finalization. Conditional `app/root.elements` and API branches are also deferred until finalization, while other bootstrap-visible predicates are ignored and reported as warnings. + +Utility APIs that are first materialized during bootstrap are frozen for the lifetime of that app instance. Finalization may still add new APIs and may override existing API refs that were not materialized during bootstrap, but any deferred override of an already materialized bootstrap API is ignored and reported as an app error. + ## Plugin Info Resolution When a plugin is installed in an app it may provide sources of information about the plugin that can be useful to end users and admins. This includes things like what version of a plugin is running, what team owns the plugin, and who to contact for support. You can read more about how the plugins provide this information in the [plugins `info` option section](./15-plugins.md#info). diff --git a/docs/frontend-system/architecture/15-plugins.md b/docs/frontend-system/architecture/15-plugins.md index c2f5f237c3..d124a0e61d 100644 --- a/docs/frontend-system/architecture/15-plugins.md +++ b/docs/frontend-system/architecture/15-plugins.md @@ -76,6 +76,20 @@ These are the routes that the plugin exposes to the app. The `routes` option dec This is a list of feature flag declarations that your plugin provides to the app. This makes sure that the feature flags are correctly registered and can be toggled in the app. To read a feature flag you can use the feature flags [Utility API](../architecture/33-utility-apis.md), accessible via `featureFlagsApiRef`. +### `if` option + +The `if` option lets you apply a shared condition to all extensions that are provided by a plugin instance. This is useful when you want to gate an entire plugin behind a feature flag or permission without repeating the same predicate on every individual extension. + +```tsx +export default createFrontendPlugin({ + pluginId: 'my-plugin', + if: { featureFlags: { $contains: 'my-plugin-enabled' } }, + extensions: [...], +}); +``` + +This predicate is applied to every extension from that plugin instance. If any extension already has its own `if` predicate, the two are combined using logical `AND`. + ### `info` option This options is used to provide loaders for different sources of information about the plugin that may be useful to users and admins. The two available loaders are `packageJson` and `manifest`, and a plugin can use either or both as needed. The resulting information is available via the `info()` method on the plugin instance once it is installed in an app, but it is up to each app to decide how to derive the information from the provided sources. diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 106ab5340a..3df33b9cad 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -41,6 +41,43 @@ Each extension in the app can be disabled, meaning it will not be instantiated a The ordering of extensions is sometimes very important, as it may for example affect in which order they show up in the UI. When an extension is toggled from disabled to enabled through configuration it resets the ordering of the extension, pushing it to the end of the list. It is generally recommended to leave extensions as disabled by default if their order is important, allowing for the order in which their are enabled in the configuration to determine their order in the app. +### Conditions + +Extensions can also be conditionally enabled by providing an `if` predicate. This is available both on `createExtension(...)` directly and when creating extensions from blueprints. + +The predicate uses the same `FilterPredicate` syntax as elsewhere in Backstage, but in the frontend system it is evaluated against app-level data such as `featureFlags` and `permissions`. For example, the following page is only installed when the `experimental-features` flag is active: + +```tsx +const examplePage = PageBlueprint.make({ + params: { + path: '/example', + loader: () => import('./ExamplePage').then(m => ), + }, + if: { featureFlags: { $contains: 'experimental-features' } }, +}); +``` + +You can also combine conditions using logical operators such as `$all`, `$any`, and `$not`: + +```tsx +const guardedCard = CardBlueprint.make({ + params: { + title: 'Guarded Card', + loader: () => import('./GuardedCard').then(m => ), + }, + if: { + $all: [ + { featureFlags: { $contains: 'experimental-features' } }, + { permissions: { $contains: 'catalog.entity.create' } }, + ], + }, +}); +``` + +Conditions are evaluated when the app tree is prepared, not continuously while the app is running. If the underlying feature flags or permissions change, the app needs to be prepared again in order for the extension tree to change, which in practice typically means reloading the app. + +If a plugin or module also provides an `if` predicate, it is combined with the extension-level predicate using logical `AND`. See the [plugin `if` option](./15-plugins.md#if-option) and [frontend modules](./25-extension-overrides.md#creating-a-frontend-module) sections for more details. + ### Configuration & configuration schema Each extension can define a configuration schema that describes the configuration that it accepts. This schema is used to validate the configuration provided by integrators, but also to fill in default configuration values. The configuration itself is provided by integrators in order to customize the extension. It is not possible to provide a default configuration of an extension, this must instead be done through defaults in the configuration schema. This allows for a simpler configuration logic where multiple configurations of the same extension completely replace each other rather than being merged. diff --git a/docs/frontend-system/architecture/23-extension-blueprints.md b/docs/frontend-system/architecture/23-extension-blueprints.md index f6efccccbb..97eb01022d 100644 --- a/docs/frontend-system/architecture/23-extension-blueprints.md +++ b/docs/frontend-system/architecture/23-extension-blueprints.md @@ -9,7 +9,7 @@ The `createExtension` function and related APIs is considered a low-level buildi ## Creating an extension from a blueprint -Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name` for the extension. +Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name`, `attachTo`, `disabled`, or `if` predicate for the extension. The following is a simple example of how one might use the blueprint `make` method to create a new extension: diff --git a/docs/frontend-system/architecture/25-extension-overrides.md b/docs/frontend-system/architecture/25-extension-overrides.md index 5e684476c8..7afbd2bacb 100644 --- a/docs/frontend-system/architecture/25-extension-overrides.md +++ b/docs/frontend-system/architecture/25-extension-overrides.md @@ -11,6 +11,8 @@ An important customization point in the frontend system is the ability to overri In general, most features should have a good level of customization built into them, so that users do not have to leverage extension overrides to achieve common goals. A well written feature often has [configuration](../../conf/) settings, or uses extension inputs for extensibility where applicable. An example of this is the search plugin, which allows you to provide result renderers as inputs rather than replacing the result page wholesale just to tweak how results are shown. Adopters should take advantage of those when possible in order to reduce the need and size of extension overrides. +Extension overrides can also replace or remove existing `if` predicates. This applies both to direct extension overrides through `.override(...)` and to plugin-level overrides through `plugin.withOverrides(...)`. Frontend modules can use the same extension override mechanism to adjust or clear the condition for an overridden extension. + ## Overriding an extension Every extension created with `createExtension` comes with an `override` method, including those created from an [extension blueprint](./23-extension-blueprints.md). The `override` method **creates a new extension**, it does not mutate the existing extension. This new extension in created in such a way that if it is installed adjacent to the existing extension, it will take precedence and override the existing extension. While the `override` method does create new extension instances, it is not intended to be used as a way to create multiple new extensions from a base template, for that use-case you will want to use an [extension blueprint](./23-extension-blueprints.md) instead. @@ -343,3 +345,5 @@ export default app.createRoot(); ``` You must define a `pluginId` when creating a frontend module, and the plugin must also be installed for the module to be loaded. + +Frontend modules also support an `if` option. Just like for plugins, that predicate is applied to every extension that comes from the module, and is combined with any extension-level `if` predicate using logical `AND`. This is useful when you want to enable or disable an entire override package based on a feature flag or permission. diff --git a/docs/frontend-system/building-apps/01-index.md b/docs/frontend-system/building-apps/01-index.md index bd9a0fbae5..cfef547afc 100644 --- a/docs/frontend-system/building-apps/01-index.md +++ b/docs/frontend-system/building-apps/01-index.md @@ -57,6 +57,8 @@ Note that `createRoot` returns the root element that is rendered by React. The a Visit the [built-in extensions](#customize-or-override-built-in-extensions) section to see what is installed by default in a Backstage application. +For advanced bootstrap flows that need access to the app tree before the full app is finalized, see [preparing an app in phases](../architecture/10-app.md#preparing-an-app-in-phases). + ## Configure your app ### Bind external routes diff --git a/docs/frontend-system/building-apps/03-built-in-extensions.md b/docs/frontend-system/building-apps/03-built-in-extensions.md index e1d0b0282d..05e60b54b7 100644 --- a/docs/frontend-system/building-apps/03-built-in-extensions.md +++ b/docs/frontend-system/building-apps/03-built-in-extensions.md @@ -99,7 +99,7 @@ This is the extension that creates the app root element, so it renders root leve | ---------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | router | A React component that should manager the app routes context. | It must be one [router](https://reactrouter.com/en/main/routers/picking-a-router#web-projects) component or a custom component compatible with the 'react-router' library. | true | [BrowserRouter](https://reactrouter.com/en/main/router-components/browser-router) | [createRouterExtension](https://backstage.io/docs/reference/frontend-plugin-api.createrouterextension) | | signInPage | A React component that should render the app sign-in page. | Should call the `onSignInSuccess` prop when the user has been successfully authorized, otherwise the user will not be correctly redirected to the application home page. | true | The default `AppRoot` extension does not use a default component for this input, it bypasses the user authentication check and always renders all routes when a login page is not installed. | [createSignInPageExtension](https://backstage.io/docs/reference/frontend-plugin-api.createsigninpageextension/) | -| children | A React component that renders the app sidebar and main content in a particular layout. | - | false | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. | +| children | A React component that renders the app sidebar and main content in a particular layout. | - | true | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. | | elements | React elements to be rendered outside of the app layout, such as shared popups. | - | false | See [default elements](#default-app-root-elements-extensions). | [createAppRootElementExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootelementextension/) | | wrappers | React components that should wrap the root element. | - | true | - | [createAppRootWrapperExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootwrapperextension/) | diff --git a/lighthouserc.js b/lighthouserc.js index c7c1275cdb..5273844c79 100644 --- a/lighthouserc.js +++ b/lighthouserc.js @@ -54,7 +54,7 @@ module.exports = { preset: 'desktop', }, startServerCommand: 'yarn start', - startServerReadyPattern: 'webpack compiled successfully', + startServerReadyPattern: 'compiled.*successfully', startServerReadyTimeout: 600000, numberOfRuns: 1, puppeteerScript: './.lighthouseci/scripts/guest-auth.js', diff --git a/packages/app/src/examples/pagesPlugin.tsx b/packages/app/src/examples/pagesPlugin.tsx index 35426b589b..531d0084fb 100644 --- a/packages/app/src/examples/pagesPlugin.tsx +++ b/packages/app/src/examples/pagesPlugin.tsx @@ -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 (
- op +

Example Pages Plugin

+

Navigation

{page1Link && (
Page 1 @@ -83,6 +87,54 @@ const IndexPage = PageBlueprint.make({
Settings
+ +

Permission Enablement Examples

+

+ The following pages demonstrate conditional extension enablement + via the if predicate using permissions. They will + only appear when the user has the required permissions. +

+
    +
  • + + Permission Gated Example + {' '} + — requires catalog.entity.create +
  • +
  • + + Permission Card Example + {' '} + — a page that is always visible, but individual cards on it are + toggled by permissions +
  • +
+ +

Feature Flag Enablement Examples

+

+ The following pages demonstrate conditional extension enablement + via the if predicate. They will only appear in the + router tree when their conditions are satisfied. Toggle the + relevant feature flags in Settings, + then refresh the app to see the pages appear. +

+
    +
  • + Feature Flag Example — + requires the experimental-features flag +
  • +
  • + All Flags Example — + requires both experimental-features and{' '} + advanced-features ($all) +
  • +
  • + Any Flag Example — requires{' '} + either experimental-features or{' '} + beta-access ($any) +
  • +
+
); @@ -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 ( +
+

Feature Flag Enabled Page

+

+ This page is only present in the app when the{' '} + experimental-features feature flag is active. +

+

+ It uses a simple{' '} + + {'{ featureFlags: { $contains: "experimental-features" } }'} + {' '} + predicate. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + 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 ( +
+

All Flags Required Page

+

+ This page requires both{' '} + experimental-features and{' '} + advanced-features to be active simultaneously. +

+

+ It uses a $all predicate to AND the two conditions + together. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + 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 ( +
+

Any Flag Sufficient Page

+

+ This page appears when either{' '} + experimental-features or beta-access is + active. +

+

+ It uses a $any predicate to OR the two conditions + together. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + 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( +
+

{params.title}

+

{params.description}

+
, + ); + }, +}); + +// 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 ( +
+

Permission-Gated Card Example

+

+ This page is always visible. The cards below are individually + gated — each one declares its own{' '} + {'if: { permissions: { $contains: "..." } }'}{' '} + predicate. Cards whose predicate fails are never instantiated, + so they simply won't appear here. +

+
+ {cards.length > 0 ? ( + cards + ) : ( +

+ No cards are visible — you may lack the required + permissions. +

+ )} +
+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }); + }, +}); + +// 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 ( +
+

Permission Gated Page

+

+ This page is only present when the user has the{' '} + catalog.entity.create permission. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + 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, + ], }); diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index a2109ec1bf..73b5126ab5 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -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`, ); } diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 56327031ec..6311976a41 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -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 () => { diff --git a/packages/cli-defaults/package.json b/packages/cli-defaults/package.json index 8a065fd4d7..7b1016d0d6 100644 --- a/packages/cli-defaults/package.json +++ b/packages/cli-defaults/package.json @@ -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:^", diff --git a/packages/cli-defaults/src/index.ts b/packages/cli-defaults/src/index.ts index dd7aed0c70..f167c3c424 100644 --- a/packages/cli-defaults/src/index.ts +++ b/packages/cli-defaults/src/index.ts @@ -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, diff --git a/packages/cli-module-actions/.eslintrc.js b/packages/cli-module-actions/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-actions/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-actions/bin/backstage-cli-module-actions b/packages/cli-module-actions/bin/backstage-cli-module-actions new file mode 100755 index 0000000000..59f8267233 --- /dev/null +++ b/packages/cli-module-actions/bin/backstage-cli-module-actions @@ -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 }); diff --git a/packages/cli-module-actions/catalog-info.yaml b/packages/cli-module-actions/catalog-info.yaml new file mode 100644 index 0000000000..fd282ca6a3 --- /dev/null +++ b/packages/cli-module-actions/catalog-info.yaml @@ -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 diff --git a/packages/cli-module-actions/cli-report.md b/packages/cli-module-actions/cli-report.md new file mode 100644 index 0000000000..bbbab62b3f --- /dev/null +++ b/packages/cli-module-actions/cli-report.md @@ -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 + -h, --help +``` + +### `backstage-cli-module-actions actions list` + +``` +Usage: @backstage/cli-module-actions actions list + +Options: + --instance + -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 +``` diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json new file mode 100644 index 0000000000..fe19a8a90d --- /dev/null +++ b/packages/cli-module-actions/package.json @@ -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:^" + } +} diff --git a/packages/cli-module-actions/report.api.md b/packages/cli-module-actions/report.api.md new file mode 100644 index 0000000000..180186482d --- /dev/null +++ b/packages/cli-module-actions/report.api.md @@ -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) +``` diff --git a/packages/cli-module-actions/src/commands/execute.ts b/packages/cli-module-actions/src/commands/execute.ts new file mode 100644 index 0000000000..4ff955096b --- /dev/null +++ b/packages/cli-module-actions/src/commands/execute.ts @@ -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: [''], + 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(); + 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 [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; + const input: Record = {}; + 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`); +}; diff --git a/packages/cli-module-actions/src/commands/list.ts b/packages/cli-module-actions/src/commands/list.ts new file mode 100644 index 0000000000..601fa6d2c7 --- /dev/null +++ b/packages/cli-module-actions/src/commands/list.ts @@ -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 " 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`); + } +}; diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts new file mode 100644 index 0000000000..2a8135b63f --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -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: [''], + }, + undefined, + args, + ); + + const pluginId = parsed._[0]; + + const instance = await getSelectedInstance(); + const existing = + (await getInstanceConfig(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`); +}; diff --git a/packages/cli-module-actions/src/commands/sourcesList.ts b/packages/cli-module-actions/src/commands/sourcesList.ts new file mode 100644 index 0000000000..68b368efd5 --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesList.ts @@ -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(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`); + } +}; diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.ts b/packages/cli-module-actions/src/commands/sourcesRemove.ts new file mode 100644 index 0000000000..731abe790d --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -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: [''], + }, + undefined, + args, + ); + + const pluginId = parsed._[0]; + + const instance = await getSelectedInstance(); + const existing = + (await getInstanceConfig(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`); +}; diff --git a/packages/cli-module-actions/src/index.ts b/packages/cli-module-actions/src/index.ts new file mode 100644 index 0000000000..57b2d741e3 --- /dev/null +++ b/packages/cli-module-actions/src/index.ts @@ -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') }, + }); + }, +}); diff --git a/packages/cli-module-actions/src/lib/ActionsClient.test.ts b/packages/cli-module-actions/src/lib/ActionsClient.test.ts new file mode 100644 index 0000000000..61526873c9 --- /dev/null +++ b/packages/cli-module-actions/src/lib/ActionsClient.test.ts @@ -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; + +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), + ); + }); + }); +}); diff --git a/packages/cli-module-actions/src/lib/ActionsClient.ts b/packages/cli-module-actions/src/lib/ActionsClient.ts new file mode 100644 index 0000000000..aa04902b2f --- /dev/null +++ b/packages/cli-module-actions/src/lib/ActionsClient.ts @@ -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 { + const results: ActionDef[] = []; + + for (const pluginId of pluginSources) { + const url = pluginActionsUrl(this.baseUrl, pluginId); + + const response = await httpJson(url, { + headers: { Authorization: `Bearer ${this.accessToken}` }, + signal: AbortSignal.timeout(30_000), + }); + + results.push(...response.actions); + } + + return results; + } + + async listForPlugin(actionId: string): Promise { + const pluginId = extractPluginId(actionId); + return this.list([pluginId]); + } + + async execute(actionId: string, input?: unknown): Promise { + const pluginId = extractPluginId(actionId); + const url = `${pluginActionsUrl( + this.baseUrl, + pluginId, + )}/${encodeURIComponent(actionId)}/invoke`; + + const response = await httpJson(url, { + method: 'POST', + headers: { Authorization: `Bearer ${this.accessToken}` }, + body: input ?? {}, + signal: AbortSignal.timeout(30_000), + }); + + return response.output; + } +} diff --git a/packages/cli-module-actions/src/lib/resolveAuth.test.ts b/packages/cli-module-actions/src/lib/resolveAuth.test.ts new file mode 100644 index 0000000000..f2702cf757 --- /dev/null +++ b/packages/cli-module-actions/src/lib/resolveAuth.test.ts @@ -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([]); + }); +}); diff --git a/packages/cli-module-actions/src/lib/resolveAuth.ts b/packages/cli-module-actions/src/lib/resolveAuth.ts new file mode 100644 index 0000000000..11fb646545 --- /dev/null +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -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(instance.name, 'pluginSources')) ?? []; + + return { instance, accessToken, pluginSources }; +} diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.test.ts b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts new file mode 100644 index 0000000000..fa9f446aae --- /dev/null +++ b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts @@ -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']); + }); +}); diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.ts b/packages/cli-module-actions/src/lib/schemaToFlags.ts new file mode 100644 index 0000000000..026613edaf --- /dev/null +++ b/packages/cli-module-actions/src/lib/schemaToFlags.ts @@ -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; + required?: string[]; +}; + +type CleyeFlag = { + type: StringConstructor | NumberConstructor | BooleanConstructor; + description?: string; + default?: unknown; +}; + +export function schemaToFlags( + schema: JsonSchemaObject, +): Record { + const flags: Record = {}; + 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; +} diff --git a/packages/cli-module-auth/package.json b/packages/cli-module-auth/package.json index 86bb491f7a..94169e998e 100644 --- a/packages/cli-module-auth/package.json +++ b/packages/cli-module-auth/package.json @@ -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", diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md index 510bcaabe7..a3b98e2f02 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -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( + instanceName: string, + key: string, +): Promise; + +// @public (undocumented) +export function getSecretStore(): Promise; + +// @public (undocumented) +export function getSelectedInstance( + instanceName?: string, +): Promise; + +// @public (undocumented) +export type HttpInit = { + headers?: Record; + method?: string; + body?: any; + signal?: AbortSignal; +}; + +// @public (undocumented) +export function httpJson(url: string, init?: HttpInit): Promise; + +// @public (undocumented) +export function refreshAccessToken( + instanceName: string, +): Promise; + +// @public (undocumented) +export type SecretStore = { + get(service: string, account: string): Promise; + set(service: string, account: string, secret: string): Promise; + delete(service: string, account: string): Promise; +}; + +// @public (undocumented) +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; + +// @public (undocumented) +export function updateInstanceConfig( + instanceName: string, + key: string, + value: unknown, +): Promise; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/cli-module-auth/src/index.ts b/packages/cli-module-auth/src/index.ts index fef74ec8a5..38567e2174 100644 --- a/packages/cli-module-auth/src/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -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'; diff --git a/packages/cli-module-auth/src/lib/auth.ts b/packages/cli-module-auth/src/lib/auth.ts index 3ea7baef5c..0344d4636e 100644 --- a/packages/cli-module-auth/src/lib/auth.ts +++ b/packages/cli-module-auth/src/lib/auth.ts @@ -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 { diff --git a/packages/cli-module-auth/src/lib/http.test.ts b/packages/cli-module-auth/src/lib/http.test.ts index 74416a0dee..40ab7009b1 100644 --- a/packages/cli-module-auth/src/lib/http.test.ts +++ b/packages/cli-module-auth/src/lib/http.test.ts @@ -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; -const mockFetch = fetch as jest.MockedFunction; +beforeEach(() => { + global.fetch = mockFetch; +}); describe('http', () => { beforeEach(() => { diff --git a/packages/cli-module-auth/src/lib/http.ts b/packages/cli-module-auth/src/lib/http.ts index 307d7d2334..4861a07722 100644 --- a/packages/cli-module-auth/src/lib/http.ts +++ b/packages/cli-module-auth/src/lib/http.ts @@ -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; method?: string; body?: any; signal?: AbortSignal; }; +/** @public */ export async function httpJson(url: string, init?: HttpInit): Promise { const res = await fetch(url, { ...init, diff --git a/packages/cli-module-auth/src/lib/secretStore.ts b/packages/cli-module-auth/src/lib/secretStore.ts index 55fac878b7..a0f3c81d51 100644 --- a/packages/cli-module-auth/src/lib/secretStore.ts +++ b/packages/cli-module-auth/src/lib/secretStore.ts @@ -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; set(service: string, account: string, secret: string): Promise; delete(service: string, account: string): Promise; @@ -89,6 +90,7 @@ class FileSecretStore implements SecretStore { let singleton: SecretStore | undefined; +/** @public */ export async function getSecretStore(): Promise { if (!singleton) { const keytar = await loadKeytar(); diff --git a/packages/cli-module-auth/src/lib/storage.test.ts b/packages/cli-module-auth/src/lib/storage.test.ts index 07ffba9510..9264c6bb89 100644 --- a/packages/cli-module-auth/src/lib/storage.test.ts +++ b/packages/cli-module-auth/src/lib/storage.test.ts @@ -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'); diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index c2eb153112..9d2551a4f9 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -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; +/** @public */ +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; 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 { @@ -160,6 +171,35 @@ export async function setSelectedInstance(name: string): Promise { }); } +/** @public */ +export async function getInstanceConfig( + instanceName: string, + key: string, +): Promise { + const instance = await getInstanceByName(instanceName); + return instance.config?.[key] as T | undefined; +} + +/** @public */ +export async function updateInstanceConfig( + instanceName: string, + key: string, + value: unknown, +): Promise { + 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(fn: () => Promise): Promise { const file = getMetadataFilePath(); await fs.ensureDir(path.dirname(file)); diff --git a/packages/cli-module-new/src/lib/defaultTemplates.ts b/packages/cli-module-new/src/lib/defaultTemplates.ts index 9d1543c452..dea35a6e99 100644 --- a/packages/cli-module-new/src/lib/defaultTemplates.ts +++ b/packages/cli-module-new/src/lib/defaultTemplates.ts @@ -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', ]; diff --git a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts index 7279801d62..63e3791a49 100644 --- a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts +++ b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts @@ -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': diff --git a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts index df128d4755..f279b702b1 100644 --- a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts +++ b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts @@ -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' }, { diff --git a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts index dd85f49fad..8a4f87c5c6 100644 --- a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts +++ b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts @@ -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': diff --git a/packages/cli-module-new/src/lib/types.ts b/packages/cli-module-new/src/lib/types.ts index 9f221ea7aa..83a195a43a 100644 --- a/packages/cli-module-new/src/lib/types.ts +++ b/packages/cli-module-new/src/lib/types.ts @@ -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; } | { diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index bb22326786..4408c6e969 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -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 + -h, --help +``` + +### `backstage-cli actions list` + +``` +Usage: backstage-cli actions list + +Options: + --instance + -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` ``` diff --git a/packages/cli/templates/cli-module/.eslintrc.js.hbs b/packages/cli/templates/cli-module/.eslintrc.js.hbs new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli/templates/cli-module/.eslintrc.js.hbs @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli/templates/cli-module/README.md.hbs b/packages/cli/templates/cli-module/README.md.hbs new file mode 100644 index 0000000000..657b45805b --- /dev/null +++ b/packages/cli/templates/cli-module/README.md.hbs @@ -0,0 +1,5 @@ +# {{packageName}} + +A CLI module that adds commands to the Backstage CLI. + +_This package was created through the Backstage CLI_ diff --git a/packages/cli/templates/cli-module/bin/{{binName}} b/packages/cli/templates/cli-module/bin/{{binName}} new file mode 100644 index 0000000000..59f8267233 --- /dev/null +++ b/packages/cli/templates/cli-module/bin/{{binName}} @@ -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 }); diff --git a/packages/cli/templates/cli-module/package.json.hbs b/packages/cli/templates/cli-module/package.json.hbs new file mode 100644 index 0000000000..5e077cca83 --- /dev/null +++ b/packages/cli/templates/cli-module/package.json.hbs @@ -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}}" +} diff --git a/packages/cli/templates/cli-module/portable-template.yaml b/packages/cli/templates/cli-module/portable-template.yaml new file mode 100644 index 0000000000..72b6c2786b --- /dev/null +++ b/packages/cli/templates/cli-module/portable-template.yaml @@ -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 }}' diff --git a/packages/cli/templates/cli-module/src/commands/example.ts b/packages/cli/templates/cli-module/src/commands/example.ts new file mode 100644 index 0000000000..66722307b0 --- /dev/null +++ b/packages/cli/templates/cli-module/src/commands/example.ts @@ -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}!`); +}; diff --git a/packages/cli/templates/cli-module/src/index.ts.hbs b/packages/cli/templates/cli-module/src/index.ts.hbs new file mode 100644 index 0000000000..a947d3d666 --- /dev/null +++ b/packages/cli/templates/cli-module/src/index.ts.hbs @@ -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') }, + }); + }, +}); diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 152b6d1141..6ee93be875 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -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:^", diff --git a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx index f8436e7434..9a18d86b1f 100644 --- a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx +++ b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx @@ -42,6 +42,7 @@ describe('convertLegacyPlugin', () => { "getExtension": [Function], "icon": undefined, "id": "test", + "if": undefined, "info": [Function], "infoOptions": undefined, "pluginId": "test", diff --git a/packages/core-components/src/components/Progress/Progress.test.tsx b/packages/core-components/src/components/Progress/Progress.test.tsx index 918c9ba518..f72454754e 100644 --- a/packages/core-components/src/components/Progress/Progress.test.tsx +++ b/packages/core-components/src/components/Progress/Progress.test.tsx @@ -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('', () => { const { queryByTestId } = await renderInTestApp(); expect(queryByTestId('progress')).toBeInTheDocument(); }); + + it('provides an accessible name for the progress bar', async () => { + await renderInTestApp(); + expect( + await screen.findByRole('progressbar', { name: 'Loading' }), + ).toBeInTheDocument(); + }); }); diff --git a/packages/core-components/src/components/Progress/Progress.tsx b/packages/core-components/src/components/Progress/Progress.tsx index b7176a5825..f1dea0ed5c 100644 --- a/packages/core-components/src/components/Progress/Progress.tsx +++ b/packages/core-components/src/components/Progress/Progress.tsx @@ -22,6 +22,7 @@ import { useTheme } from '@material-ui/core/styles'; import { PropsWithChildren, useEffect, useState } from 'react'; export function Progress(props: PropsWithChildren) { + const { 'aria-label': ariaLabel, ...progressProps } = props; const theme = useTheme(); const [isVisible, setIsVisible] = useState(false); @@ -34,7 +35,11 @@ export function Progress(props: PropsWithChildren) { }, [theme.transitions.duration.short]); return isVisible ? ( - + ) : ( ); diff --git a/packages/core-plugin-api/report.api.md b/packages/core-plugin-api/report.api.md index 654bbe94d1..7165cfc697 100644 --- a/packages/core-plugin-api/report.api.md +++ b/packages/core-plugin-api/report.api.md @@ -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(config: ApiRefConfig): ApiRef; // @public export function createComponentExtension< diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts index dab872236f..5bfb83ce3e 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts @@ -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({ id: 'abc', pluginId: 'test' }); }); it('should reject invalid ids', () => { diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.ts b/packages/core-plugin-api/src/apis/system/ApiRef.ts index ffd074b9a7..8c3986c583 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.ts @@ -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 ( + config: ApiRefConfig, +) => ApiRef; + +/** + * Creates a reference to an API. + * + * @public + */ +export function createApiRef(config: ApiRefConfig): ApiRef { + return createFrontendApiRefCompat(config); +} + +export type { ApiRefConfig }; diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 1951c18828..f5dea017a9 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -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", diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index bf69cc83e6..7795794051 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -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, context: { node: AppNode; - apis: ApiHolder_2; + apis: ApiHolder; config?: JsonObject; }, ) => Iterable>; +// @public +export type FinalizedSpecializedApp = { + element: JSX.Element; + sessionState: SpecializedAppSessionState; + tree: AppTree; + errors?: AppError[]; +}; + // @public export type FrontendPluginInfoResolver = (ctx: { packageJson(): Promise; @@ -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'; +}; ``` diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index e52bbfa320..f5fc9f2e3a 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -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, + 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(); + }); + }); }); diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 9e8e3cc2eb..8cef89f497 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -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 = { -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; + reuseExistingInstances?: boolean; + writeNodeInstances?: boolean; +}; + function resolveV1InputDataMap( dataMap: { [name in string]: ExtensionDataRef; @@ -336,12 +351,28 @@ export function createAppNodeInstance(options: { apis: ApiHolder; attachments: ReadonlyMap; 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(); const extensionDataRefs = new Set>(); + 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(); + + 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(); + + 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).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(); - - 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; } - } + | Record, +): 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; + } = + optionsOrPredicateContext && + ('stopAtAttachment' in optionsOrPredicateContext || + 'skipChild' in optionsOrPredicateContext || + 'onMissingApi' in optionsOrPredicateContext || + 'predicateContext' in optionsOrPredicateContext) + ? optionsOrPredicateContext + : { + predicateContext: optionsOrPredicateContext, + }; - (node as Mutable).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 + ); } diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 3ccdf5258f..ec56394b39 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -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().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().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().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().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().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(); + }); }); diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index a095acee0e..0e56fa4a98 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -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; +}) { + 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, diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts new file mode 100644 index 0000000000..65e6010182 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts @@ -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({ 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({ id: 'test.false' }); + const dependentApiRef = createApiRef({ 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, + }); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts new file mode 100644 index 0000000000..00d565170a --- /dev/null +++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts @@ -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(); + + 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) { + for (const factory of factories) { + this.set(factory); + } + } + + get( + api: ApiRef, + ): ApiFactory | undefined { + const factory = this.factories.get(api.id); + if (!factory) { + return undefined; + } + + return factory as ApiFactory; + } + + getAllApis() { + const refs = new Set(); + for (const factory of this.factories.values()) { + refs.add(factory.api); + } + return refs; + } +} + +export class FrontendApiResolver implements ApiHolder { + private readonly apis = new Map(); + 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(ref: ApiRef): T | undefined { + return this.load(ref); + } + + isMaterialized(apiRefId: string) { + return this.apis.has(apiRefId); + } + + invalidate(apiRefIds?: Iterable) { + if (apiRefIds === undefined) { + this.apis.clear(); + return; + } + + for (const apiRefId of apiRefIds) { + this.apis.delete(apiRefId); + } + } + + private load(ref: ApiRef, 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; + } +} diff --git a/packages/frontend-app-api/src/wiring/apiFactories.ts b/packages/frontend-app-api/src/wiring/apiFactories.ts new file mode 100644 index 0000000000..6ccc75b9c1 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/apiFactories.ts @@ -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; + appApiRegistry: FrontendApiRegistry; + apiResolver: FrontendApiResolver; + collector: ErrorCollector; + features: FrontendFeature[]; + bootstrapApiFactoryEntries: ReadonlyMap; + bootstrapMissingApiAccesses: Map; + 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; + collector: ErrorCollector; + predicateContext?: ExtensionPredicateContext; + entries?: Map; +}): Map { + const factoriesById = options.entries ?? new Map(); + 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; +} diff --git a/packages/frontend-app-api/src/wiring/createErrorCollector.ts b/packages/frontend-app-api/src/wiring/createErrorCollector.ts index 5f081bc092..7077e0e9c4 100644 --- a/packages/frontend-app-api/src/wiring/createErrorCollector.ts +++ b/packages/frontend-app-api/src/wiring/createErrorCollector.ts @@ -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 }; diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts index bfab0ae9a3..9a88590d7c 100644 --- a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts @@ -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, diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 51c31aca25..3e841a1283 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -27,16 +27,32 @@ import { createExternalRouteRef, createExtensionInput, useRouteRef, + useApi, analyticsApiRef, createExtensionDataRef, } from '@backstage/frontend-plugin-api'; -import { screen, render } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { createSpecializedApp } from './createSpecializedApp'; +import { + FinalizedSpecializedApp, + prepareSpecializedApp, + PreparedSpecializedApp, +} from './prepareSpecializedApp'; import { mockApis, TestApiRegistry } from '@backstage/test-utils'; -import { configApiRef, featureFlagsApiRef } from '@backstage/core-plugin-api'; +import { + configApiRef, + featureFlagsApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; import { MemoryRouter } from 'react-router-dom'; import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; -import { Fragment } from 'react'; +import { ComponentType, Fragment, useEffect, useState } from 'react'; +import appPluginOriginal from '@backstage/plugin-app'; + +const signInPageComponentDataRef = createExtensionDataRef< + ComponentType<{ onSignInSuccess(identity: IdentityApi): void }> +>().with({ id: 'core.sign-in-page.component' }); function makeAppPlugin(label: string = 'Test') { return createFrontendPlugin({ @@ -50,13 +66,38 @@ function makeAppPlugin(label: string = 'Test') { ], }); } + +function renderPreparedBootstrap(preparedApp: PreparedSpecializedApp) { + const bootstrapApp = preparedApp.getBootstrapApp(); + const bootstrapElement = bootstrapApp.element; + if (!bootstrapElement) { + throw new Error('Expected bootstrap tree to expose a root element'); + } + + render(bootstrapElement); +} + +async function waitForFinalizedApp(preparedApp: PreparedSpecializedApp) { + return new Promise(resolve => { + preparedApp.onFinalized(resolve); + }); +} + describe('createSpecializedApp', () => { + const appPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal.getExtension('app/layout').override({ + factory: () => [coreExtensionData.reactElement(
App Layout
)], + }), + ], + }); + it('should render the root app', () => { const app = createSpecializedApp({ features: [makeAppPlugin()], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(screen.getByText('Test')).toBeInTheDocument(); }); @@ -66,7 +107,7 @@ describe('createSpecializedApp', () => { features: [makeAppPlugin('Test 1'), makeAppPlugin('Test 2')], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(screen.getByText('Test 2')).toBeInTheDocument(); }); @@ -92,11 +133,41 @@ describe('createSpecializedApp', () => { ], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(screen.getByText('Test foo')).toBeInTheDocument(); }); + it('should warn and ignore bootstrap-visible if predicates', () => { + const app = createSpecializedApp({ + features: [ + createFrontendPlugin({ + pluginId: 'test', + extensions: [ + createExtension({ + name: 'conditional-root', + attachTo: { id: 'root', input: 'app' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Ignored Predicate
), + ], + }), + ], + }), + ], + }); + + render(app.element); + + expect(screen.getByText('Ignored Predicate')).toBeInTheDocument(); + expect(app.errors).toEqual([ + expect.objectContaining({ + code: 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED', + }), + ]); + }); + it('should support APIs and feature flags', async () => { const flags = new Array<{ name: string; pluginId: string }>(); const app = createSpecializedApp({ @@ -145,7 +216,7 @@ describe('createSpecializedApp', () => { ], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(screen.getByText('flags:test=a,test=b')).toBeInTheDocument(); @@ -153,81 +224,7 @@ describe('createSpecializedApp', () => { { name: 'a', pluginId: 'test' }, { name: 'b', pluginId: 'test', description: 'Feature B description' }, ]); - - expect(app.apis).toMatchInlineSnapshot(` - ApiResolver { - "apis": Map { - "core.featureflags" => { - "getRegisteredFlags": [Function], - "registerFlag": [Function], - }, - }, - "factories": ApiFactoryRegistry { - "factories": Map { - "core.featureflags" => { - "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.featureflags", - }, - }, - "deps": {}, - "factory": [Function], - }, - "priority": 10, - }, - "core.app-tree" => { - "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.app-tree", - }, - }, - "deps": {}, - "factory": [Function], - }, - "priority": 100, - }, - "core.config" => { - "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.config", - }, - }, - "deps": {}, - "factory": [Function], - }, - "priority": 100, - }, - "core.route-resolution" => { - "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.route-resolution", - }, - }, - "deps": {}, - "factory": [Function], - }, - "priority": 100, - }, - "core.identity" => { - "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.identity", - }, - }, - "deps": {}, - "factory": [Function], - }, - "priority": 100, - }, - }, - }, - } - `); + expect(app.sessionState).toBeDefined(); }); it('should initialize the APIs in the correct order to allow for overrides', () => { @@ -292,17 +289,31 @@ describe('createSpecializedApp', () => { ], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(mockAnalyticsApi).toHaveBeenCalled(); }); it('should select the API factory from the owning plugin on conflict', () => { const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' }); + const appRootPlugin = createFrontendPlugin({ + pluginId: 'app', + extensions: [ + createExtension({ + attachTo: { id: 'root', input: 'app' }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => [ + coreExtensionData.reactElement( +
Selected API: {apis.get(testApiRef)!.value}
, + ), + ], + }), + ], + }); const app = createSpecializedApp({ features: [ - makeAppPlugin(), + appRootPlugin, createFrontendPlugin({ pluginId: 'other-before', extensions: [ @@ -356,15 +367,30 @@ describe('createSpecializedApp', () => { }), ]); - expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' }); + render(app.element); + expect(screen.getByText('Selected API: owner')).toBeInTheDocument(); }); it('should allow API overrides within the same plugin', () => { const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' }); + const appRootPlugin = createFrontendPlugin({ + pluginId: 'app', + extensions: [ + createExtension({ + attachTo: { id: 'root', input: 'app' }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => [ + coreExtensionData.reactElement( +
Selected API: {apis.get(testApiRef)!.value}
, + ), + ], + }), + ], + }); const app = createSpecializedApp({ features: [ - makeAppPlugin(), + appRootPlugin, createFrontendPlugin({ pluginId: 'test', extensions: [ @@ -395,17 +421,13 @@ describe('createSpecializedApp', () => { }); expect(app.errors).toBeUndefined(); - expect(app.apis.get(testApiRef)).toEqual({ value: 'module' }); + render(app.element); + expect(screen.getByText('Selected API: module')).toBeInTheDocument(); }); - it('should use provided apis', async () => { + it('should reuse provided apis', async () => { + const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' }); const app = createSpecializedApp({ - advanced: { - apis: TestApiRegistry.from([ - configApiRef, - new ConfigReader({ anything: 'config' }), - ]), - }, features: [ createFrontendPlugin({ pluginId: 'test', @@ -416,8 +438,9 @@ describe('createSpecializedApp', () => { factory: ({ apis }) => [ coreExtensionData.reactElement(
- providedApis: - {apis.get(configApiRef)!.getString('anything')} + reusedApis: + {apis.get(configApiRef)!.getString('anything')}: + {apis.get(testApiRef)!.value}
, ), ], @@ -425,28 +448,17 @@ describe('createSpecializedApp', () => { ], }), ], + advanced: { + apis: TestApiRegistry.from( + [configApiRef, new ConfigReader({ anything: 'config' })], + [testApiRef, { value: 'from-apis' }], + ), + }, }); render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); - expect(screen.getByText('providedApis:config')).toBeInTheDocument(); - - expect(app.apis).toMatchInlineSnapshot(` - TestApiRegistry { - "apis": Map { - "core.config" => ConfigReader { - "context": "mock-config", - "data": { - "anything": "config", - }, - "fallback": undefined, - "filteredKeys": undefined, - "notifiedFilteredKeys": Set {}, - "prefix": "", - }, - }, - } - `); + expect(screen.getByText('reusedApis:config:from-apis')).toBeInTheDocument(); }); it('should make the app structure available through the AppTreeApi', async () => { @@ -882,4 +894,897 @@ describe('createSpecializedApp', () => { }); }); }); + + describe('prepareSpecializedApp', () => { + it('should accept session state through advanced options', () => { + const originalApp = createSpecializedApp({ + features: [makeAppPlugin('Original')], + }); + const preparedApp = prepareSpecializedApp({ + features: [makeAppPlugin('Prepared')], + advanced: { + sessionState: originalApp.sessionState, + }, + }); + + const app = preparedApp.finalize(); + + render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + + expect(screen.getByText('Prepared')).toBeInTheDocument(); + }); + + it('should reject finalize after selecting onFinalized', () => { + const preparedApp = prepareSpecializedApp({ + features: [makeAppPlugin()], + }); + + const unsubscribe = preparedApp.onFinalized(() => {}); + + expect(() => preparedApp.finalize()).toThrow( + 'prepareSpecializedApp only supports using either onFinalized() or finalize(), not both', + ); + + unsubscribe(); + }); + + it('should reject onFinalized after selecting finalize', () => { + const preparedApp = prepareSpecializedApp({ + features: [makeAppPlugin()], + }); + + preparedApp.finalize(); + + expect(() => preparedApp.onFinalized(() => {})).toThrow( + 'prepareSpecializedApp only supports using either onFinalized() or finalize(), not both', + ); + }); + + it('should synchronously finalize feature flag predicates 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 noSignInAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal + .getExtension('sign-in-page:app') + .override({ disabled: true }), + ], + }); + const preparedApp = prepareSpecializedApp({ + features: [ + noSignInAppPlugin, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + createExtension({ + name: 'bootstrap-element', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Bootstrap Element
), + ], + }), + createExtension({ + name: 'deferred-element', + attachTo: { id: 'app/root', input: 'elements' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Deferred Element
), + ], + }), + ], + }), + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + + expect(screen.getByText('Bootstrap Element')).toBeInTheDocument(); + expect(screen.queryByText('Deferred Element')).not.toBeInTheDocument(); + + const finalizedApp = preparedApp.finalize(); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + await expect( + screen.findByText('Deferred Element'), + ).resolves.toBeInTheDocument(); + }); + + it('should defer conditional api roots without resetting visible api instances', () => { + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const visibleApiExtension = ApiBlueprint.make({ + name: 'visible-api', + params: defineParams => + defineParams({ + api: createApiRef<{ value: string }>({ id: 'test.visible-api' }), + deps: {}, + factory: () => ({ value: 'visible' }), + }), + }); + const deferredApiExtension = ApiBlueprint.make({ + name: 'deferred-api', + params: defineParams => + defineParams({ + api: createApiRef<{ value: string }>({ id: 'test.deferred-api' }), + deps: {}, + factory: () => ({ value: 'deferred' }), + }), + }).override({ + if: { featureFlags: { $contains: 'test-flag' } }, + }); + const preparedApp = prepareSpecializedApp({ + features: [ + makeAppPlugin(), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + visibleApiExtension, + deferredApiExtension, + ApiBlueprint.make({ + name: 'feature-flags', + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + ], + }); + + const bootstrapTree = preparedApp.getBootstrapApp().tree; + const apiNodes = bootstrapTree.root.edges.attachments.get('apis') ?? []; + const visibleApiNode = apiNodes.find( + node => node.spec.id === 'api:test/visible-api', + ); + const deferredApiNode = apiNodes.find( + node => node.spec.id === 'api:test/deferred-api', + ); + + expect(visibleApiNode?.instance).toBeDefined(); + expect(deferredApiNode?.instance).toBeUndefined(); + + const visibleApiInstance = visibleApiNode?.instance; + preparedApp.finalize(); + + expect(visibleApiNode?.instance).toBe(visibleApiInstance); + expect(deferredApiNode?.instance).toBeDefined(); + }); + + it('should ignore deferred overrides of materialized bootstrap APIs', () => { + const apiRef = createApiRef<{ value: string }>({ + id: 'test.bootstrap-frozen-api', + }); + let bootstrapApiValue: string | undefined; + let finalApiValue: string | undefined; + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const noSignInAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal + .getExtension('sign-in-page:app') + .override({ disabled: true }), + ], + }); + const preparedApp = prepareSpecializedApp({ + features: [ + noSignInAppPlugin, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + ApiBlueprint.make({ + name: 'bootstrap-api', + params: defineParams => + defineParams({ + api: apiRef, + deps: {}, + factory: () => ({ value: 'bootstrap' }), + }), + }), + ApiBlueprint.make({ + name: 'deferred-api', + params: defineParams => + defineParams({ + api: apiRef, + deps: {}, + factory: () => ({ value: 'final' }), + }), + }).override({ + if: { featureFlags: { $contains: 'test-flag' } }, + }), + createExtension({ + name: 'bootstrap-reader', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => { + bootstrapApiValue = apis.get(apiRef)?.value; + return [ + coreExtensionData.reactElement(
Bootstrap Reader
), + ]; + }, + }), + createExtension({ + name: 'final-reader', + attachTo: { id: 'app/root', input: 'elements' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => { + finalApiValue = apis.get(apiRef)?.value; + return [ + coreExtensionData.reactElement(
Final Reader
), + ]; + }, + }), + ], + }), + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + expect(bootstrapApiValue).toBe('bootstrap'); + + const finalizedApp = preparedApp.finalize(); + + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + expect(finalApiValue).toBe('bootstrap'); + expect(finalizedApp.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED', + context: expect.objectContaining({ + apiRefId: apiRef.id, + }), + }), + ]), + ); + }); + + it('should allow deferred overrides of bootstrap APIs that were not materialized', () => { + const apiRef = createApiRef<{ value: string }>({ + id: 'test.bootstrap-overridable-api', + }); + let finalApiValue: string | undefined; + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const noSignInAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal + .getExtension('sign-in-page:app') + .override({ disabled: true }), + ], + }); + const preparedApp = prepareSpecializedApp({ + features: [ + noSignInAppPlugin, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + ApiBlueprint.make({ + name: 'bootstrap-api', + params: defineParams => + defineParams({ + api: apiRef, + deps: {}, + factory: () => ({ value: 'bootstrap' }), + }), + }), + ApiBlueprint.make({ + name: 'deferred-api', + params: defineParams => + defineParams({ + api: apiRef, + deps: {}, + factory: () => ({ value: 'final' }), + }), + }).override({ + if: { featureFlags: { $contains: 'test-flag' } }, + }), + createExtension({ + name: 'bootstrap-element', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Bootstrap Element
), + ], + }), + createExtension({ + name: 'final-reader', + attachTo: { id: 'app/root', input: 'elements' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => { + finalApiValue = apis.get(apiRef)?.value; + return [ + coreExtensionData.reactElement(
Final Reader
), + ]; + }, + }), + ], + }), + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + expect(screen.getByText('Bootstrap Element')).toBeInTheDocument(); + + const finalizedApp = preparedApp.finalize(); + + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + expect(finalApiValue).toBe('final'); + expect(finalizedApp.errors ?? []).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED', + context: expect.objectContaining({ + apiRefId: apiRef.id, + }), + }), + ]), + ); + }); + + it('should defer app root children until finalize', 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 appLayoutFactory = jest.fn(() => [ + coreExtensionData.reactElement(
App Layout
), + ]); + const phasedAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal.getExtension('app/layout').override({ + factory: appLayoutFactory, + }), + ], + }); + let onSignInSuccess: ((identity: IdentityApi) => void) | undefined; + + const preparedApp = prepareSpecializedApp({ + features: [ + phasedAppPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + phasedAppPlugin.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + onSignInSuccess = props.onSignInSuccess; + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + const finalizedAppPromise = waitForFinalizedApp(preparedApp); + if (!onSignInSuccess) { + throw new Error('Expected sign-in success callback to be captured'); + } + const triggerSignInSuccess = onSignInSuccess; + act(() => { + triggerSignInSuccess(identityApi); + }); + + const finalizedApp = await finalizedAppPromise; + expect(appLayoutFactory).toHaveBeenCalledTimes(1); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + expect(screen.getByText('App Layout')).toBeInTheDocument(); + expect(appLayoutFactory).toHaveBeenCalledTimes(1); + }); + + it('should expose a sign-in page element and finalize with the captured identity', 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 identityAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal.getExtension('app/layout').override({ + factory: () => { + function IdentityReader() { + const [identity, setIdentity] = useState(); + const appIdentityApi = useApi(identityApiRef); + + useEffect(() => { + void appIdentityApi.getBackstageIdentity().then(result => { + setIdentity(result.userEntityRef); + }); + }, [appIdentityApi]); + + return
Identity: {identity}
; + } + + return [coreExtensionData.reactElement()]; + }, + }), + ], + }); + let onSignInSuccess: ((identity: IdentityApi) => void) | undefined; + + const preparedApp = prepareSpecializedApp({ + features: [ + identityAppPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + identityAppPlugin.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + onSignInSuccess = props.onSignInSuccess; + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + const finalizedAppPromise = waitForFinalizedApp(preparedApp); + if (!onSignInSuccess) { + throw new Error('Expected sign-in success callback to be captured'); + } + const triggerSignInSuccess = onSignInSuccess; + act(() => { + triggerSignInSuccess(identityApi); + }); + + const finalizedApp = await finalizedAppPromise; + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + await expect( + screen.findByText('Identity: user:default/test-user'), + ).resolves.toBeInTheDocument(); + }); + + it('should reuse predicate context gathered during sign-in completion', 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((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const gatedAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal.getExtension('app/layout').override({ + if: { featureFlags: { $contains: 'test-flag' } }, + factory: () => [ + coreExtensionData.reactElement(
Flagged Layout
), + ], + }), + ], + }); + let onSignInSuccess: ((identity: IdentityApi) => void) | undefined; + + const preparedApp = prepareSpecializedApp({ + features: [ + gatedAppPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + gatedAppPlugin.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + onSignInSuccess = props.onSignInSuccess; + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + const finalizedAppPromise = waitForFinalizedApp(preparedApp); + if (!onSignInSuccess) { + throw new Error('Expected sign-in success callback to be captured'); + } + const triggerSignInSuccess = onSignInSuccess; + act(() => { + triggerSignInSuccess(identityApi); + }); + + const finalizedApp = await finalizedAppPromise; + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + expect(featureFlagsApi.isActive).toHaveBeenCalledTimes(1); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + expect(screen.getByText('Flagged Layout')).toBeInTheDocument(); + }); + + it('should defer conditional app root elements until finalize', 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((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + + const preparedApp = prepareSpecializedApp({ + features: [ + appPluginOriginal, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + createExtension({ + name: 'bootstrap-element', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Bootstrap Element
), + ], + }), + createExtension({ + name: 'deferred-element', + attachTo: { id: 'app/root', input: 'elements' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Deferred Element
), + ], + }), + ], + }), + 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; + }) { + useEffect(() => { + props.onSignInSuccess(identityApi); + }, [props]); + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Bootstrap Element'), + ).resolves.toBeInTheDocument(); + expect(screen.queryByText('Deferred Element')).not.toBeInTheDocument(); + + const finalizedApp = await waitForFinalizedApp(preparedApp); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + await expect( + screen.findByText('Deferred Element'), + ).resolves.toBeInTheDocument(); + }); + + it('should warn when bootstrap extensions access APIs that appear during finalization', 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 delayedApiRef = createApiRef<{ value: string }>({ + id: 'test.delayed-api', + }); + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + + const preparedApp = prepareSpecializedApp({ + features: [ + appPluginOriginal, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + createExtension({ + name: 'bootstrap-api-reader', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => { + const delayedApi = apis.get(delayedApiRef); + return [ + coreExtensionData.reactElement( +
+ Bootstrap API:{' '} + {delayedApi ? delayedApi.value : 'missing'} +
, + ), + ]; + }, + }), + ApiBlueprint.make({ + name: 'delayed-api', + params: defineParams => + defineParams({ + api: delayedApiRef, + deps: {}, + factory: () => ({ value: 'ready' }), + }), + }).override({ + if: { featureFlags: { $contains: 'test-flag' } }, + }), + ], + }), + 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; + }) { + useEffect(() => { + props.onSignInSuccess(identityApi); + }, [props]); + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Bootstrap API: missing'), + ).resolves.toBeInTheDocument(); + + const finalizedApp = await waitForFinalizedApp(preparedApp); + + expect(finalizedApp.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE', + context: expect.objectContaining({ + apiRefId: delayedApiRef.id, + }), + }), + ]), + ); + }); + + it('should gate finalize behind internal async sign-in finalization', 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 () => {}, + }; + let onSignInSuccess: ((identity: IdentityApi) => void) | undefined; + + const preparedApp = prepareSpecializedApp({ + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + appPlugin.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + onSignInSuccess = props.onSignInSuccess; + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + expect(() => preparedApp.finalize()).toThrow( + 'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()', + ); + + if (!onSignInSuccess) { + throw new Error('Expected sign-in success callback to be captured'); + } + const triggerSignInSuccess = onSignInSuccess; + act(() => { + triggerSignInSuccess(identityApi); + }); + expect(() => preparedApp.finalize()).toThrow( + 'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()', + ); + }); + }); }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index fda00b70b1..dc2f8e0562 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -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(); - 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; - private readonly appBasePath: string; - - constructor( - routeBindings: Map, - appBasePath: string, - ) { - this.routeBindings = routeBindings; - this.appBasePath = appBasePath; - } - - resolve( - anyRouteRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, - options?: { sourcePath?: string }, - ): RouteFunc | 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, - ) { - 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(); } diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index cae2117df5..d23e60ff0c 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -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, diff --git a/packages/frontend-app-api/src/wiring/phaseApis.tsx b/packages/frontend-app-api/src/wiring/phaseApis.tsx new file mode 100644 index 0000000000..781d4c5c1c --- /dev/null +++ b/packages/frontend-app-api/src/wiring/phaseApis.tsx @@ -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; + private readonly appBasePath: string; + + constructor( + routeBindings: Map, + appBasePath: string, + ) { + this.routeBindings = routeBindings; + this.appBasePath = appBasePath; + } + + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + options?: { sourcePath?: string }, + ): RouteFunc | 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, + ) { + 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[0]) => void) + | undefined; + + setTargetHandlers(options: { + onTargetSet?( + identityApi: Parameters[0], + ): void; + }) { + this.#onTargetSet = options.onTargetSet; + } + + clearTargetHandlers() { + this.#onTargetSet = undefined; + } + + override setTarget( + identityApi: Parameters[0], + targetOptions: Parameters[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; + 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; + 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, + }); +} diff --git a/packages/frontend-app-api/src/wiring/predicates.ts b/packages/frontend-app-api/src/wiring/predicates.ts new file mode 100644 index 0000000000..819036fc17 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/predicates.ts @@ -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; +}; + +export const localPermissionApiRef = createApiRef({ + 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(); + const permissions = new Set(); + + 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; + 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).$contains; + if (typeof contains === 'string') { + return [contains]; + } + } + return []; +} diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx new file mode 100644 index 0000000000..13b5c8e8f0 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -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(); + 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; + 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 +>().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(); + 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; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + routeRefsById: ReturnType; + appBasePath: string; + providedApis?: ApiHolder; + features: FrontendFeature[]; + appApiRegistry: FrontendApiRegistry; + bootstrapClassification: BootstrapClassification; + bootstrapApiFactoryEntries: Map; + bootstrapMissingApiAccesses: Map; +}): 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; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + routeRefsById: ReturnType; + 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((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) { + 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); + }; + }); +} diff --git a/packages/frontend-app-api/src/wiring/treeLifecycle.tsx b/packages/frontend-app-api/src/wiring/treeLifecycle.tsx new file mode 100644 index 0000000000..671464359a --- /dev/null +++ b/packages/frontend-app-api/src/wiring/treeLifecycle.tsx @@ -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; + deferredElementRoots: Set; + deferredRoots: Set; +}; + +/** + * 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; + 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([ + ...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; + 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(); + 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(dataRef: ExtensionDataRef) { + if (dataRef.id === coreExtensionData.reactElement.id) { + return () as TValue; + } + return undefined; + }, + }; +} + +function getAppRootNode(tree: AppTree) { + return tree.nodes.get('app/root'); +} + +function getFinalizationBoundaryNodes(tree: AppTree): AppNode[] { + const nodes = new Set(); + 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 }, +) { + const visibleNodes = new Set(); + + 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(); + + 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); +} diff --git a/packages/frontend-defaults/package.json b/packages/frontend-defaults/package.json index 0ff81a490f..622763a6c9 100644 --- a/packages/frontend-defaults/package.json +++ b/packages/frontend-defaults/package.json @@ -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", diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 72bd0d785e..cfe4f55b2d 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -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'; diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 233f0b5460..9fa6b2d91a 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -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
Sign In API: {api.value}
; + }; + + 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 ( +
+ Flags:{' '} + {flagsApi + .getRegisteredFlags() + .map(flag => flag.name) + .join(', ')} +
+ ); + }; + + 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
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + PageBlueprint.make({ + if: { featureFlags: { $contains: 'test-flag' } }, + params: { + path: '/', + loader: async () =>
Flagged Page
, + }, + }), + ], + }), + ], + }); + + 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 () =>
Derp
, + loader: async () =>
Flagged Page
, }, }), ], }), ], - 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 () =>
All Flags Page
, + }, + }), + ], + }), + ], + }); + + 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 () =>
All Flags Page
, + }, + }), + ], + }), + ], + }); + + 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 () =>
Any Flag Page
, + }, + }), + ], + }), + ], + }); + + 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 () =>
Any Flag Page
, + }, + }), + ], + }), + ], + }); + + 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 () =>
Permission Page
, + }, + }), + ], + }), + ], + }); + + 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 () =>
Permission Page
, + }, + }), + ], + }), + ], + }); + + 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(
{params.title}
); + }, + }); + + const page = PageBlueprint.makeWithOverrides({ + name: 'card-page', + inputs: { + cards: createExtensionInput([coreExtensionData.reactElement], { + optional: false, + singleton: false, + }), + }, + factory(originalFactory, { inputs }) { + return originalFactory({ + path: '/', + loader: async () => ( +
+ {inputs.cards.map(card => + card.get(coreExtensionData.reactElement), + )} +
+ ), + }); + }, + }); + + 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', () => { ] - signInPage [ - - ] ] diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 220a7b743f..728ec29e42 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -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: () => , + }; } 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; +} diff --git a/packages/frontend-defaults/src/maybeCreateErrorPage.tsx b/packages/frontend-defaults/src/maybeCreateErrorPage.tsx index 152c37fdd3..8c058b838f 100644 --- a/packages/frontend-defaults/src/maybeCreateErrorPage.tsx +++ b/packages/frontend-defaults/src/maybeCreateErrorPage.tsx @@ -24,6 +24,8 @@ const DEFAULT_WARNING_CODES: Array = [ '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 { diff --git a/packages/frontend-internal/package.json b/packages/frontend-internal/package.json index 52cdb33641..abfac8a51a 100644 --- a/packages/frontend-internal/package.json +++ b/packages/frontend-internal/package.json @@ -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:^" diff --git a/packages/frontend-internal/src/apis/OpaqueApiRef.ts b/packages/frontend-internal/src/apis/OpaqueApiRef.ts new file mode 100644 index 0000000000..8a1b058641 --- /dev/null +++ b/packages/frontend-internal/src/apis/OpaqueApiRef.ts @@ -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 & { + readonly $$type: '@backstage/ApiRef'; + }; + versions: { + readonly version: 'v1'; + readonly pluginId?: string; + }; +}>({ + type: '@backstage/ApiRef', + versions: ['v1'], +}); diff --git a/packages/frontend-internal/src/apis/index.ts b/packages/frontend-internal/src/apis/index.ts new file mode 100644 index 0000000000..f445683652 --- /dev/null +++ b/packages/frontend-internal/src/apis/index.ts @@ -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'; diff --git a/packages/frontend-internal/src/index.ts b/packages/frontend-internal/src/index.ts index 38bfdc53f8..447b488fca 100644 --- a/packages/frontend-internal/src/index.ts +++ b/packages/frontend-internal/src/index.ts @@ -15,4 +15,5 @@ */ export * from './routing'; +export * from './apis'; export * from './wiring'; diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts index b7c3674f9a..dcadccb305 100644 --- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts +++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts @@ -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; @@ -69,6 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{ readonly name?: string; readonly attachTo: ExtensionDefinitionAttachTo; readonly disabled: boolean; + readonly if?: FilterPredicate; readonly configSchema?: PortableSchema; readonly inputs: { [inputName in string]: ExtensionInput }; readonly output: Array; diff --git a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts index 4564be2bb1..31af631a59 100644 --- a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts +++ b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts @@ -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[]; readonly featureFlags: FeatureFlagConfig[]; + readonly if?: FilterPredicate; readonly infoOptions?: { packageJson?: () => Promise; manifest?: () => Promise; diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index 568d542a38..66973d4406 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "zod": "^3.25.76", diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index 76678107ef..db4570ee2f 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -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(api: ApiRef): T | undefined; +}; + +// @public +export type ApiRef = { + 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; +} + +// @public +export interface AppNodeInstance { + getData(ref: ExtensionDataRef): T | undefined; + getDataRefs(): Iterable>; +} + +// @public +export interface AppNodeSpec { + // (undocumented) + readonly attachTo: ExtensionAttachTo; + // (undocumented) + readonly config?: unknown; + // (undocumented) + readonly disabled: boolean; + // (undocumented) + readonly extension: Extension; + // (undocumented) + readonly id: string; + // (undocumented) + readonly if?: FilterPredicate; + // (undocumented) + readonly plugin: FrontendPlugin; +} + +// @public +export interface AppTree { + readonly nodes: ReadonlyMap; + readonly orphans: Iterable; + readonly root: AppNode; +} + +// @public (undocumented) +export interface ConfigurableExtensionDataRef< + TData, + TId extends string, + TConfig extends { + optional?: true; + } = {}, +> extends ExtensionDataRef { + // (undocumented) + (t: TData): ExtensionDataValue; + // (undocumented) + optional(): ConfigurableExtensionDataRef< + TData, + TId, + TConfig & { + optional: true; + } + >; +} + +// @public +export function createExtensionBlueprintParams( + params: T, +): ExtensionBlueprintParams; + +// @public (undocumented) +export interface Extension { + // (undocumented) + $$type: '@backstage/Extension'; + // (undocumented) + readonly attachTo: ExtensionAttachTo; + // (undocumented) + readonly configSchema?: PortableSchema; + // (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>, + UParentInputs extends ExtensionDataRef, + >(args: { + name?: TName; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo, 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: `.make({ params: defineParams => defineParams() })`' + : 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, + UNewOutput extends ExtensionDataRef, + UParentInputs extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + } = {}, + >(args: { + name?: TName; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + config?: { + schema: TExtensionConfigSchema & { + [KName in keyof T['config']]?: `Error: Config key '${KName & + string}' is already defined in parent schema`; + }; + }; + factory( + originalFactory: < + TParamsInput extends AnyParamsInput>, + >( + 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())`' + : T['params'], + context?: { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + }, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }; + inputs: Expand>; + }, + ): Iterable & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UFactoryOutput + >; + }): OverridableExtensionDefinition<{ + config: Expand< + (string extends keyof TExtensionConfigSchema + ? {} + : { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }) & + 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; + 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; + +// @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 = { + $$type: '@backstage/BlueprintParams'; + T: T; +}; + +// @public (undocumented) +export type ExtensionDataContainer = + Iterable< + UExtensionData extends ExtensionDataRef< + infer IData, + infer IId, + infer IConfig + > + ? IConfig['optional'] extends true + ? never + : ExtensionDataValue + : never + > & { + get( + ref: ExtensionDataRef, + ): UExtensionData extends ExtensionDataRef + ? 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 = { + 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; + +// @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; + // (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; + 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 { + readonly inputs: { + [K in keyof T['inputs']]: ExtensionInput< + T['inputs'][K] extends ExtensionInput ? IData : never + >; + }; + // (undocumented) + override< + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + UFactoryOutput extends ExtensionDataValue, + UNewOutput extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + }, + TParamsInput extends AnyParamsInput_2>, + UParentInputs extends ExtensionDataRef, + >( + args: Expand< + { + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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 + >, + >( + context?: Expand< + { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + } & ([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())`' + : Partial; + }) + >, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }; + inputs: Expand>; + }, + ): Iterable; + } & ([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())`' + : Partial; + }) + > & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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 + >; + }; + 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; +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: (params: { loader: () => Promise>; - }) => ExtensionBlueprintParams<{ + }) => ExtensionBlueprintParams_2<{ loader: () => Promise; }>; - output: ExtensionDataRef< + output: ExtensionDataRef_2< () => Promise, 'core.plugin-wrapper.loader', {} @@ -43,7 +611,7 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{ config: {}; configInput: {}; dataRefs: { - wrapper: ConfigurableExtensionDataRef< + wrapper: ConfigurableExtensionDataRef_2< () => Promise, 'core.plugin-wrapper.loader', {} @@ -60,5 +628,33 @@ export type PluginWrapperDefinition = { }>; }; +// @public (undocumented) +export type PortableSchema = { + 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) ``` diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index d0fede34b6..6e144273b9 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -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; }; -// @public @deprecated -export const alertApiRef: ApiRef; +// @public +export const alertApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public @deprecated export type AlertMessage = { @@ -46,7 +49,9 @@ export type AnalyticsApi = { }; // @public -export const analyticsApiRef: ApiRef; +export const analyticsApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export const AnalyticsContext: (options: { @@ -187,9 +192,10 @@ export type ApiHolder = { }; // @public -export type ApiRef = { - id: string; - T: T; +export type ApiRef = { + readonly $$type?: '@backstage/ApiRef'; + readonly id: TId; + readonly T: T; }; // @public @@ -212,7 +218,9 @@ export type AppLanguageApi = { }; // @public (undocumented) -export const appLanguageApiRef: ApiRef; +export const appLanguageApiRef: ApiRef_2 & { + 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; +export const appThemeApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export interface AppTree { @@ -305,12 +317,17 @@ export interface AppTreeApi { } // @public -export const appTreeApiRef: ApiRef_2; +export const appTreeApiRef: ApiRef_2 & { + 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; +export const configApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export interface ConfigurableExtensionDataRef< @@ -415,8 +440,22 @@ export function createApiFactory( instance: Impl, ): ApiFactory; +// @public @deprecated +export function createApiRef(config: ApiRefConfig): ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; + // @public -export function createApiRef(config: ApiRefConfig): ApiRef; +export function createApiRef(): { + with( + config: ApiRefConfig & { + id: TId; + pluginId?: string; + }, + ): ApiRef & { + readonly $$type: '@backstage/ApiRef'; + }; +}; // @public export function createExtension< @@ -541,6 +580,7 @@ export type CreateExtensionBlueprintOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + if?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -627,6 +667,7 @@ export type CreateExtensionOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + if?: FilterPredicate; inputs?: TInputs; output: Array; 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, + options: CreateFrontendPluginOptions< + TId, + TRoutes, + TExternalRoutes, + TExtensions + >, ): OverridableFrontendPlugin< TRoutes, TExternalRoutes, MakeSortedExtensionsMap >; +// @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 { } // @public -export const dialogApiRef: ApiRef_2; +export const dialogApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type DiscoveryApi = { @@ -877,7 +956,9 @@ export type DiscoveryApi = { }; // @public -export const discoveryApiRef: ApiRef; +export const discoveryApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type ErrorApi = { @@ -901,7 +982,9 @@ export type ErrorApiErrorContext = { }; // @public -export const errorApiRef: ApiRef; +export const errorApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export const ErrorDisplay: { @@ -952,6 +1035,7 @@ export interface ExtensionBlueprint< attachTo?: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo, 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; +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; +export const fetchApiRef: ApiRef_2 & { + 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; +export const iconsApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type IdentityApi = { @@ -1443,16 +1546,21 @@ export type IdentityApi = { }; // @public -export const identityApiRef: ApiRef; +export const identityApiRef: ApiRef_2 & { + 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; +export const oauthRequestApiRef: ApiRef_2< + OAuthRequestApi, + 'core.oauthrequest' +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type OAuthRequester = ( @@ -1532,22 +1645,28 @@ export type OAuthRequesterOptions = { 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; + if?: FilterPredicate; title?: string; icon?: IconElement; info?: FrontendPluginInfoOptions; @@ -1845,10 +1969,15 @@ export type PluginHeaderActionsApi = { }; // @public -export const pluginHeaderActionsApiRef: ApiRef_2; +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; // @public export type PluginWrapperApi = { @@ -1887,7 +2001,12 @@ export type PluginWrapperApi = { }; // @public -export const pluginWrapperApiRef: ApiRef_2; +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>> - : false extends TInputs[InputName]['config']['optional'] - ? Expand> - : Expand | undefined>; -}; - // @public export type RouteFunc = ( ...input: TParams extends undefined ? readonly [] : readonly [params: TParams] @@ -1993,7 +2099,12 @@ export interface RouteResolutionApi { } // @public -export const routeResolutionApiRef: ApiRef_2; +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; +export const storageApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type StorageValueSnapshot = @@ -2121,7 +2234,12 @@ export interface SwappableComponentsApi { } // @public -export const swappableComponentsApiRef: ApiRef_2; +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; +export const translationApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export type TranslationFunction< @@ -2360,13 +2480,16 @@ export const useTranslationRef: ( }; // @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( diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts index dfd7f1e2bf..e822c13f88 100644 --- a/packages/frontend-plugin-api/src/alpha.ts +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -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, diff --git a/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts index 6f90f9d56f..88de012707 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts @@ -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 = createApiRef({ +export const alertApiRef = createApiRef().with({ id: 'core.alert', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts index aa1f08ffbe..bd5d9a4394 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -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 = createApiRef({ +export const analyticsApiRef = createApiRef().with({ id: 'core.analytics', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts index 36b97f9fff..1b1b7437b5 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts @@ -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 = createApiRef({ +export const appLanguageApiRef = createApiRef().with({ id: 'core.applanguage', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts index e771fad597..66669e8cab 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts @@ -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 = createApiRef({ +export const appThemeApiRef = createApiRef().with({ id: 'core.apptheme', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts index 89902d2727..901216b554 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts @@ -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; readonly disabled: boolean; + readonly if?: FilterPredicate; readonly config?: unknown; readonly plugin: FrontendPlugin; } @@ -117,4 +119,7 @@ export interface AppTreeApi { * * @public */ -export const appTreeApiRef = createApiRef({ id: 'core.app-tree' }); +export const appTreeApiRef = createApiRef().with({ + id: 'core.app-tree', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts index f935dfa3af..8cc1638d90 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts @@ -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 = createApiRef({ +export const configApiRef = createApiRef().with({ id: 'core.config', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts index d7ab2e5d75..bcb92f528a 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts @@ -173,6 +173,7 @@ export interface DialogApi { * * @public */ -export const dialogApiRef = createApiRef({ +export const dialogApiRef = createApiRef().with({ id: 'core.dialog', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts b/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts index d23fe3db6c..824174c8f6 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts @@ -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 = createApiRef({ +export const discoveryApiRef = createApiRef().with({ id: 'core.discovery', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts index 9c73d94cac..e7be718326 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts @@ -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 = createApiRef({ +export const errorApiRef = createApiRef().with({ id: 'core.error', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts index d4429975cc..b7d1c4ccaa 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts @@ -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 = createApiRef({ +export const featureFlagsApiRef = createApiRef().with({ id: 'core.featureflags', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts b/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts index aba0e53bb7..5299878c34 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts @@ -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 = createApiRef({ +export const fetchApiRef = createApiRef().with({ id: 'core.fetch', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts index d22ebcce4a..a54ae7f3b1 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts @@ -41,6 +41,7 @@ export interface IconsApi { * * @public */ -export const iconsApiRef = createApiRef({ +export const iconsApiRef = createApiRef().with({ id: 'core.icons', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts b/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts index 1b127a1971..70b6ebf56a 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts @@ -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 = createApiRef({ +export const identityApiRef = createApiRef().with({ id: 'core.identity', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts b/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts index 75bf3a3864..d94ab44d15 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts @@ -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 = createApiRef({ +export const oauthRequestApiRef = createApiRef().with({ id: 'core.oauthrequest', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts index 78d0e2623f..9e2d1ca0fd 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts @@ -40,6 +40,8 @@ export type PluginHeaderActionsApi = { * * @public */ -export const pluginHeaderActionsApiRef = createApiRef({ - id: 'core.plugin-header-actions', -}); +export const pluginHeaderActionsApiRef = + createApiRef().with({ + id: 'core.plugin-header-actions', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts index 8d9b224b1f..7965b8ccb4 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts @@ -47,6 +47,7 @@ export type PluginWrapperApi = { * * @public */ -export const pluginWrapperApiRef = createApiRef({ +export const pluginWrapperApiRef = createApiRef().with({ id: 'core.plugin-wrapper', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts b/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts index 0c3ca9c4cf..9dea5d7bc2 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts @@ -65,6 +65,7 @@ export interface RouteResolutionApi { * * @public */ -export const routeResolutionApiRef = createApiRef({ +export const routeResolutionApiRef = createApiRef().with({ id: 'core.route-resolution', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts b/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts index 1506e5f143..1624f8402c 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { JsonValue, Observable } from '@backstage/types'; /** @@ -105,6 +105,7 @@ export interface StorageApi { * * @public */ -export const storageApiRef: ApiRef = createApiRef({ +export const storageApiRef = createApiRef().with({ id: 'core.storage', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts index 09dff04f43..47ffed91ef 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts @@ -36,6 +36,8 @@ export interface SwappableComponentsApi { * * @public */ -export const swappableComponentsApiRef = createApiRef({ - id: 'core.swappable-components', -}); +export const swappableComponentsApiRef = + createApiRef().with({ + id: 'core.swappable-components', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts index b569800e3e..ab50baf5e1 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { Expand, ExpandRecursive, Observable } from '@backstage/types'; import { TranslationRef } from '../../translation'; import { JSX } from 'react'; @@ -358,6 +358,7 @@ export type TranslationApi = { /** * @public */ -export const translationApiRef: ApiRef = createApiRef({ +export const translationApiRef = createApiRef().with({ id: 'core.translation', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/auth.ts b/packages/frontend-plugin-api/src/apis/definitions/auth.ts index 76aa24e8f8..d686ed5d08 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/auth.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/auth.ts @@ -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'; import { IconComponent, IconElement } from '../../icons/types'; import { Observable } from '@backstage/types'; @@ -28,7 +28,10 @@ import { Observable } from '@backstage/types'; * For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect, * would be declared as follows: * - * const googleAuthApiRef = createApiRef({ ... }) + * const googleAuthApiRef = createApiRef().with({ + * id: 'core.auth.google', + * pluginId: 'app', + * }) */ /** @@ -333,14 +336,15 @@ export type SessionApi = { * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, * email and expiration information. Do not rely on any other fields, as they might not be present. */ -export const googleAuthApiRef: ApiRef< +export const googleAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.google', + pluginId: 'app', }); /** @@ -352,10 +356,11 @@ export const googleAuthApiRef: ApiRef< * See {@link https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/} * for a full list of supported scopes. */ -export const githubAuthApiRef: ApiRef< +export const githubAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.github', + pluginId: 'app', }); /** @@ -367,14 +372,15 @@ export const githubAuthApiRef: ApiRef< * See {@link https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/} * for a full list of supported scopes. */ -export const oktaAuthApiRef: ApiRef< +export const oktaAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.okta', + pluginId: 'app', }); /** @@ -386,14 +392,15 @@ export const oktaAuthApiRef: ApiRef< * See {@link https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token} * for a full list of supported scopes. */ -export const gitlabAuthApiRef: ApiRef< +export const gitlabAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.gitlab', + pluginId: 'app', }); /** @@ -406,14 +413,15 @@ export const gitlabAuthApiRef: ApiRef< * - {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent} * - {@link https://docs.microsoft.com/en-us/graph/permissions-reference} */ -export const microsoftAuthApiRef: ApiRef< +export const microsoftAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.microsoft', + pluginId: 'app', }); /** @@ -421,14 +429,15 @@ export const microsoftAuthApiRef: ApiRef< * * @public */ -export const oneloginAuthApiRef: ApiRef< +export const oneloginAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.onelogin', + pluginId: 'app', }); /** @@ -440,10 +449,11 @@ export const oneloginAuthApiRef: ApiRef< * See {@link https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/} * for a full list of supported scopes. */ -export const bitbucketAuthApiRef: ApiRef< +export const bitbucketAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.bitbucket', + pluginId: 'app', }); /** @@ -455,10 +465,11 @@ export const bitbucketAuthApiRef: ApiRef< * See {@link https://confluence.atlassian.com/bitbucketserver/bitbucket-oauth-2-0-provider-api-1108483661.html#BitbucketOAuth2.0providerAPI-scopes} * for a full list of supported scopes. */ -export const bitbucketServerAuthApiRef: ApiRef< +export const bitbucketServerAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.bitbucket-server', + pluginId: 'app', }); /** @@ -470,10 +481,11 @@ export const bitbucketServerAuthApiRef: ApiRef< * See {@link https://developer.atlassian.com/cloud/jira/platform/scopes-for-connect-and-oauth-2-3LO-apps/} * for a full list of supported scopes. */ -export const atlassianAuthApiRef: ApiRef< +export const atlassianAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.atlassian', + pluginId: 'app', }); /** @@ -485,14 +497,15 @@ export const atlassianAuthApiRef: ApiRef< * For more info about VMware Cloud identity and access management: * - {@link https://docs.vmware.com/en/VMware-Cloud-services/services/Using-VMware-Cloud-Services/GUID-53D39337-D93A-4B84-BD18-DDF43C21479A.html} */ -export const vmwareCloudAuthApiRef: ApiRef< +export const vmwareCloudAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.vmware-cloud', + pluginId: 'app', }); /** @@ -506,8 +519,9 @@ export const vmwareCloudAuthApiRef: ApiRef< * {@link https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html-single/authentication_and_authorization/index#tokens-scoping-about_configuring-internal-oauth} * for available scopes. */ -export const openshiftAuthApiRef: ApiRef< +export const openshiftAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +>().with({ id: 'core.auth.openshift', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts index dab872236f..37bf0205be 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts @@ -15,13 +15,54 @@ */ import { createApiRef } from './ApiRef'; +import type { ApiRef as ApiRefType } from './types'; describe('ApiRef', () => { - it('should be created', () => { + it('should be created with config', () => { 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 with deprecated config form', () => { + expect(createApiRef({ id: 'abc' }).id).toBe('abc'); + + // @ts-expect-error pluginId is only supported through .with(...) + createApiRef({ id: 'abc', pluginId: 'test' }); + }); + + it('should keep the deprecated config form id wide', () => { + const ref = createApiRef({ id: 'abc' }); + const wideRef: ApiRefType = ref; + expect(wideRef.id).toBe('abc'); + + // @ts-expect-error deprecated config form should not infer literal ids + const literalRef: ApiRefType = ref; + expect(literalRef.id).toBe('abc'); + }); + + it('should be created with builder pattern', () => { + const ref = createApiRef().with({ id: 'abc', pluginId: 'test' }); + expect(ref.$$type).toBe('@backstage/ApiRef'); + expect(ref.id).toBe('abc'); + expect(String(ref)).toBe('apiRef{abc}'); + expect(ref.T).toBeNull(); + expect((ref as { pluginId?: string }).pluginId).toBe('test'); + + // @ts-expect-error pluginId is internal runtime metadata + expect(ref.pluginId).toBe('test'); + }); + + it('should infer literal ids with builder pattern', () => { + const ref = createApiRef().with({ id: 'abc', pluginId: 'test' }); + const literalRef: ApiRefType = ref; + expect(literalRef.id).toBe('abc'); + + // @ts-expect-error builder pattern should preserve literal ids + const wrongLiteralRef: ApiRefType = ref; + expect(wrongLiteralRef.id).toBe('abc'); }); it('should reject invalid ids', () => { @@ -47,4 +88,10 @@ describe('ApiRef', () => { ); } }); + + it('should reject invalid ids with builder pattern', () => { + expect(() => createApiRef().with({ id: '123' })).toThrow( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '123'`, + ); + }); }); diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 0db3d89bd5..557341d98a 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { OpaqueApiRef } from '@internal/frontend'; import type { ApiRef } from './types'; /** @@ -25,48 +26,110 @@ export type ApiRefConfig = { id: string; }; -class ApiRefImpl implements ApiRef { - constructor(private readonly config: ApiRefConfig) { - const valid = config.id - .split('.') - .flatMap(part => part.split('-')) - .every(part => part.match(/^[a-z][a-z0-9]*$/)); - if (!valid) { - throw new Error( - `API id must only contain period separated lowercase alphanum tokens with dashes, got '${config.id}'`, - ); - } - } +type ApiRefBuilderConfig = { + id: TId; + pluginId?: string; +}; - get id(): string { - return this.config.id; - } - - // Utility for getting type of an api, using `typeof apiRef.T` - get T(): T { - throw new Error(`tried to read ApiRef.T of ${this}`); - } - - toString() { - return `apiRef{${this.config.id}}`; +function validateId(id: string): void { + const valid = id + .split('.') + .flatMap(part => part.split('-')) + .every(part => part.match(/^[a-z][a-z0-9]*$/)); + if (!valid) { + throw new Error( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '${id}'`, + ); } } +function makeApiRef( + config: ApiRefBuilderConfig, +): ApiRef & { readonly $$type: '@backstage/ApiRef' } { + return OpaqueApiRef.createInstance('v1', { + id: config.id, + ...(config.pluginId ? { pluginId: config.pluginId } : {}), + T: null as unknown as T, + toString() { + return `apiRef{${config.id}}`; + }, + }) as ApiRef & { readonly $$type: '@backstage/ApiRef' }; +} + /** - * Creates a reference to an API. The provided `id` is a stable identifier for - * the API implementation. + * Creates a reference to an API. * * @remarks * - * The frontend system infers the owning plugin for an API from the `id`. The + * The `id` is a stable identifier for the API implementation. The frontend + * system infers the owning plugin for an API from the `id`. When using the + * builder form, you can instead provide a `pluginId` explicitly. The * recommended pattern is `plugin..*` (for example, * `plugin.catalog.entity-presentation`). This ensures that other plugins can't * mistakenly override your API implementation. * - * @param config - The descriptor of the API to reference. - * @returns An API reference. + * The recommended way to create an API reference is: + * + * ```ts + * const myApiRef = createApiRef().with({ + * id: 'my-api', + * pluginId: 'my-plugin', + * }); + * ``` + * + * The legacy way to create an API reference is: + * + * ```ts + * const myApiRef = createApiRef({ id: 'plugin.my.api' }); + * ``` + * * @public */ -export function createApiRef(config: ApiRefConfig): ApiRef { - return new ApiRefImpl(config); +/** + * Creates a reference to an API. + * + * @deprecated Use `createApiRef().with(...)` instead. + * @public + */ +export function createApiRef( + config: ApiRefConfig, +): ApiRef & { readonly $$type: '@backstage/ApiRef' }; +/** + * Creates a reference to an API. + * + * @remarks + * + * Returns a builder with a `.with()` method for providing the API reference + * configuration. + * + * @public + */ +export function createApiRef(): { + with( + config: ApiRefConfig & { id: TId; pluginId?: string }, + ): ApiRef & { + readonly $$type: '@backstage/ApiRef'; + }; +}; +export function createApiRef(config?: ApiRefConfig): + | (ApiRef & { readonly $$type: '@backstage/ApiRef' }) + | { + with( + config: ApiRefConfig & { id: TId; pluginId?: string }, + ): ApiRef & { + readonly $$type: '@backstage/ApiRef'; + }; + } { + if (config) { + validateId(config.id); + return makeApiRef(config); + } + return { + with( + withConfig: ApiRefConfig & { id: TId; pluginId?: string }, + ): ApiRef & { readonly $$type: '@backstage/ApiRef' } { + validateId(withConfig.id); + return makeApiRef(withConfig); + }, + }; } diff --git a/packages/frontend-plugin-api/src/apis/system/types.ts b/packages/frontend-plugin-api/src/apis/system/types.ts index 96614c320c..5bc6136bdf 100644 --- a/packages/frontend-plugin-api/src/apis/system/types.ts +++ b/packages/frontend-plugin-api/src/apis/system/types.ts @@ -19,9 +19,10 @@ * * @public */ -export type ApiRef = { - id: string; - T: T; +export type ApiRef = { + readonly $$type?: '@backstage/ApiRef'; + readonly id: TId; + readonly T: T; }; /** diff --git a/packages/frontend-plugin-api/src/apis/system/useApi.test.tsx b/packages/frontend-plugin-api/src/apis/system/useApi.test.tsx index 7596105810..a5cfb627ac 100644 --- a/packages/frontend-plugin-api/src/apis/system/useApi.test.tsx +++ b/packages/frontend-plugin-api/src/apis/system/useApi.test.tsx @@ -38,7 +38,9 @@ describe('useApiHolder', () => { const renderedHook = renderHook(() => useApiHolder()); const holder = renderedHook.result.current; - expect(holder.get(createApiRef({ id: 'x' }))).toBeUndefined(); + expect( + holder.get(createApiRef().with({ id: 'x' })), + ).toBeUndefined(); }); }); @@ -53,7 +55,7 @@ describe('useApi', () => { const get = jest.fn(() => 'my-api-impl'); context.set({ 1: { get } }); - const apiRef = createApiRef({ id: 'x' }); + const apiRef = createApiRef().with({ id: 'x' }); const renderedHook = renderHook(() => useApi(apiRef)); const value = renderedHook.result.current; diff --git a/packages/frontend-plugin-api/src/blueprints/AnalyticsImplementationBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/AnalyticsImplementationBlueprint.test.ts index 06ad9b877e..6d43e0352f 100644 --- a/packages/frontend-plugin-api/src/blueprints/AnalyticsImplementationBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/AnalyticsImplementationBlueprint.test.ts @@ -39,6 +39,7 @@ describe('AnalyticsBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "analytics", "name": "test", diff --git a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts index 8043752687..b39abb175b 100644 --- a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts @@ -20,7 +20,7 @@ import { createApiRef } from '../apis/system'; describe('ApiBlueprint', () => { it('should create an extension with sensible defaults', () => { - const api = createApiRef<{ foo: string }>({ id: 'test' }); + const api = createApiRef<{ foo: string }>().with({ id: 'test' }); const extension = ApiBlueprint.make({ params: defineParams => @@ -43,6 +43,7 @@ describe('ApiBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "api", "name": "test", @@ -57,8 +58,8 @@ describe('ApiBlueprint', () => { }); it('should properly type the API factory', () => { - const fooApi = createApiRef<{ foo: string }>({ id: 'foo' }); - const barApi = createApiRef<{ bar: string }>({ id: 'bar' }); + const fooApi = createApiRef<{ foo: string }>().with({ id: 'foo' }); + const barApi = createApiRef<{ bar: string }>().with({ id: 'bar' }); expect('test').not.toBe('failing without assertions'); @@ -152,7 +153,7 @@ describe('ApiBlueprint', () => { }); it('should create an extension with custom factory', () => { - const api = createApiRef<{ foo: string }>({ id: 'test' }); + const api = createApiRef<{ foo: string }>().with({ id: 'test' }); const factory = jest.fn(() => ({ foo: 'bar' })); const extension = ApiBlueprint.makeWithOverrides({ @@ -196,6 +197,7 @@ describe('ApiBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": { "test": { "$$type": "@backstage/ExtensionInput", diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx index 57984761ac..0e477dd2f1 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx @@ -42,6 +42,7 @@ describe('AppRootElementBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "app-root-element", "name": undefined, diff --git a/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx index 8656cc7a33..b3be3fb8a8 100644 --- a/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx @@ -53,6 +53,7 @@ describe('NavItemBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "nav-item", "name": undefined, diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx index 8fc9c56bfe..925c370af6 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx @@ -65,6 +65,7 @@ describe('PageBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": { "pages": { "$$type": "@backstage/ExtensionInput", diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 329205ae76..8632b3caeb 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -36,6 +36,7 @@ import { } from './createExtensionBlueprint'; import { FrontendPlugin } from './createFrontendPlugin'; import { FrontendModule } from './createFrontendModule'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * This symbol is used to pass parameter overrides from the extension override to the blueprint factory @@ -53,7 +54,8 @@ type ResolvedExtensionInput = /** * Converts an extension input map into a matching collection of resolved inputs. - * @public + * + * @ignore */ export type ResolvedExtensionInputs< TInputs extends { @@ -173,6 +175,7 @@ export type CreateExtensionOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + if?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -254,6 +257,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`; @@ -473,6 +477,7 @@ export function createExtension< name: options.name, attachTo: options.attachTo, disabled: options.disabled ?? false, + if: options.if, inputs: bindInputs(options.inputs, options.kind, options.name), output: options.output, configSchema, @@ -544,12 +549,18 @@ export function createExtension< ); } + let ifPredicate = options.if; + if ('if' in overrideOptions) { + ifPredicate = overrideOptions.if; + } + return createExtension({ kind: options.kind, name: options.name, attachTo: (overrideOptions.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: overrideOptions.disabled ?? options.disabled, + if: ifPredicate, inputs: bindInputs( { ...(options.inputs ?? {}), diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index a7537d27e9..2d2288b120 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -36,6 +36,7 @@ import { } from './resolveInputOverrides'; import { ExtensionDataContainer } from './types'; import { PageBlueprint } from '../blueprints/PageBlueprint'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * A function used to define a parameter mapping function in order to facilitate @@ -114,6 +115,7 @@ export type CreateExtensionBlueprintOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + if?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -221,6 +223,7 @@ export interface ExtensionBlueprint< attachTo?: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; + if?: FilterPredicate; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput : T['params'] extends ExtensionBlueprintDefineParams @@ -261,6 +264,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`; @@ -510,6 +514,7 @@ export function createExtensionBlueprint< attachTo: (args.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, + if: args.if ?? options.if, inputs: options.inputs, output: options.output as ExtensionDataRef[], config: options.config, @@ -527,6 +532,7 @@ export function createExtensionBlueprint< attachTo: (args.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, + if: args.if ?? options.if, inputs: { ...args.inputs, ...options.inputs }, output: (args.output ?? options.output) as ExtensionDataRef[], config: diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts index d895f6de0f..6f7d8c12c8 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts @@ -46,6 +46,7 @@ describe('createFrontendModule', () => { "disabled": false, "factory": [Function], "id": "route:test/test", + "if": undefined, "inputs": {}, "output": [], "toString": [Function], @@ -53,6 +54,7 @@ describe('createFrontendModule', () => { }, ], "featureFlags": [], + "if": undefined, "pluginId": "test", "toString": [Function], "version": "v1", diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts index cea0760e68..1d77ce2167 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts @@ -21,6 +21,7 @@ import { resolveExtensionDefinition, } from './resolveExtensionDefinition'; import { FeatureFlagConfig } from './types'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** @public */ export interface CreateFrontendModuleOptions< @@ -30,6 +31,7 @@ export interface CreateFrontendModuleOptions< pluginId: TPluginId; extensions?: TExtensions; featureFlags?: FeatureFlagConfig[]; + if?: FilterPredicate; } /** @public */ @@ -43,6 +45,7 @@ export interface InternalFrontendModule extends FrontendModule { readonly version: 'v1'; readonly extensions: Extension[]; readonly featureFlags: FeatureFlagConfig[]; + readonly if?: FilterPredicate; } /** @@ -126,6 +129,7 @@ export function createFrontendModule< version: 'v1', pluginId, featureFlags: options.featureFlags ?? [], + if: options.if, extensions, toString() { return `Module{pluginId=${pluginId}}`; diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts index 1e1f7e1a84..a6034db1e2 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts @@ -171,6 +171,7 @@ describe('createFrontendPlugin', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": undefined, "name": "1", @@ -359,6 +360,7 @@ describe('createFrontendPlugin', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": undefined, "name": "1", diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index 08329b6fd4..9e9c58c4b6 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -32,6 +32,7 @@ import { JsonObject } from '@backstage/types'; import { IconElement } from '../icons/types'; import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing'; import { ID_PATTERN } from './constants'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * Information about the plugin. @@ -115,6 +116,11 @@ export interface OverridableFrontendPlugin< withOverrides(options: { extensions?: Array; + /** + * Overrides the shared condition that applies to all extensions in the plugin. + */ + if?: FilterPredicate; + /** * Overrides the display title of the plugin. */ @@ -170,8 +176,12 @@ export interface FrontendPlugin< info(): Promise; } -/** @public */ -export interface PluginOptions< +/** + * Options for {@link createFrontendPlugin}. + * + * @public + */ +export interface CreateFrontendPluginOptions< TId extends string, TRoutes extends { [name in string]: RouteRef | SubRouteRef }, TExternalRoutes extends { [name in string]: ExternalRouteRef }, @@ -191,9 +201,21 @@ export interface PluginOptions< externalRoutes?: TExternalRoutes; extensions?: TExtensions; featureFlags?: FeatureFlagConfig[]; + if?: FilterPredicate; info?: FrontendPluginInfoOptions; } +/** + * @deprecated Use {@link CreateFrontendPluginOptions} instead. + * @public + */ +export type PluginOptions< + TId extends string, + TRoutes extends { [name in string]: RouteRef | SubRouteRef }, + TExternalRoutes extends { [name in string]: ExternalRouteRef }, + TExtensions extends readonly ExtensionDefinition[], +> = CreateFrontendPluginOptions; + /** * Creates a new plugin that can be installed in a Backstage app. * @@ -230,7 +252,12 @@ export function createFrontendPlugin< TRoutes extends { [name in string]: RouteRef | SubRouteRef } = {}, TExternalRoutes extends { [name in string]: ExternalRouteRef } = {}, >( - options: PluginOptions, + options: CreateFrontendPluginOptions< + TId, + TRoutes, + TExternalRoutes, + TExtensions + >, ): OverridableFrontendPlugin< TRoutes, TExternalRoutes, @@ -284,6 +311,7 @@ export function createFrontendPlugin< routes: options.routes ?? ({} as TRoutes), externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes), featureFlags: options.featureFlags ?? [], + if: options.if, extensions: extensions, infoOptions: options.info, @@ -306,6 +334,10 @@ export function createFrontendPlugin< return `Plugin{id=${pluginId}}`; }, withOverrides(overrides) { + let ifPredicate = options.if; + if ('if' in overrides) { + ifPredicate = overrides.if; + } const overrideExtensions = overrides.extensions ?? []; const overriddenExtensionIds = new Set( overrideExtensions.map( @@ -321,6 +353,7 @@ export function createFrontendPlugin< return createFrontendPlugin({ ...options, pluginId, + if: ifPredicate, title: overrides.title ?? options.title, icon: overrides.icon ?? options.icon, extensions: [...nonOverriddenExtensions, ...overrideExtensions], diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index d3acef46b6..f3a39cc60d 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -22,7 +22,6 @@ export { type ExtensionDefinitionParameters, type CreateExtensionOptions, type OverridableExtensionDefinition, - type ResolvedExtensionInputs, } from './createExtension'; export { createExtensionInput, @@ -36,6 +35,7 @@ export { } from './createExtensionDataRef'; export { createFrontendPlugin, + type CreateFrontendPluginOptions, type FrontendPlugin, type OverridableFrontendPlugin, type PluginOptions, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index 70a0bd7837..a14c4e7863 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -28,6 +28,7 @@ import { OpaqueExtensionDefinition, OpaqueExtensionInput, } from '@internal/frontend'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** @public */ export type ExtensionAttachTo = { id: string; input: string }; @@ -74,6 +75,7 @@ export type InternalExtension = Extension< } | { readonly version: 'v2'; + readonly if?: FilterPredicate; readonly inputs: { [inputName in string]: ExtensionInput }; readonly output: Array; factory(options: { diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 13de1ef239..59c5545a81 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -34,6 +34,7 @@ "@backstage/config": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-app-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-app": "workspace:^", diff --git a/packages/frontend-test-utils/src/apis/TestApiProvider.test.tsx b/packages/frontend-test-utils/src/apis/TestApiProvider.test.tsx index 1bb5673751..5d27e3a228 100644 --- a/packages/frontend-test-utils/src/apis/TestApiProvider.test.tsx +++ b/packages/frontend-test-utils/src/apis/TestApiProvider.test.tsx @@ -38,6 +38,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should allow partial API implementations', () => { @@ -46,6 +48,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should reject mismatched types in tuple syntax', () => { @@ -55,6 +59,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should accept MockWithApiFactory entries', () => { @@ -63,6 +69,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should accept a mix of tuples and MockWithApiFactory entries', () => { @@ -71,6 +79,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should allow empty APIs', () => { @@ -79,6 +89,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should provide APIs at runtime', async () => { diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index eeb0e02353..4e62d55c4d 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -16,7 +16,7 @@ import { Fragment } from 'react'; import { Link, MemoryRouter } from 'react-router-dom'; -import { createSpecializedApp } from '@backstage/frontend-app-api'; +import { prepareSpecializedApp } from '@backstage/frontend-app-api'; import { RenderResult, render } from '@testing-library/react'; import { ConfigReader } from '@backstage/config'; import { JsonObject } from '@backstage/types'; @@ -233,7 +233,7 @@ export function renderInTestApp( features.push(...options.features); } - const app = createSpecializedApp({ + const app = prepareSpecializedApp({ features, config: ConfigReader.fromConfigs([ { @@ -251,7 +251,7 @@ export function renderInTestApp( return createApiFactory(apiRef, implementation); }), }, - } as CreateSpecializedAppInternalOptions); + } as CreateSpecializedAppInternalOptions).finalize(); return render( app.tree.root.instance!.getData(coreExtensionData.reactElement), diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx index f470be7dbb..7708fa4220 100644 --- a/packages/frontend-test-utils/src/app/renderTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -15,7 +15,7 @@ */ import { Fragment } from 'react'; -import { createSpecializedApp } from '@backstage/frontend-app-api'; +import { prepareSpecializedApp } from '@backstage/frontend-app-api'; import { coreExtensionData, createApiFactory, @@ -175,7 +175,7 @@ export function renderTestApp( features.push(...options.features); } - const app = createSpecializedApp({ + const app = prepareSpecializedApp({ features, config: ConfigReader.fromConfigs([ { @@ -193,7 +193,7 @@ export function renderTestApp( return createApiFactory(apiRef, implementation); }), }, - } as CreateSpecializedAppInternalOptions); + } as CreateSpecializedAppInternalOptions).finalize(); return render( app.tree.root.instance!.getData(coreExtensionData.reactElement), diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 0b11df6055..364592a6ff 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -839,7 +839,7 @@ export const CheckboxDefinition: { // @public (undocumented) export type CheckboxOwnProps = { - children: React.ReactNode; + children?: React.ReactNode; className?: string; }; diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx index d7b0bd6f76..e0d4605739 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { forwardRef } from 'react'; +import { forwardRef, useEffect } from 'react'; import { Checkbox as RACheckbox } from 'react-aria-components'; import type { CheckboxProps } from './types'; import { useDefinition } from '../../hooks/useDefinition'; @@ -29,6 +29,16 @@ export const Checkbox = forwardRef( props, ); const { classes, children } = ownProps; + const ariaLabel = restProps['aria-label']; + const ariaLabelledBy = restProps['aria-labelledby']; + + useEffect(() => { + if (!children && !ariaLabel && !ariaLabelledBy) { + console.warn( + 'Checkbox requires either a visible label, aria-label, or aria-labelledby for accessibility', + ); + } + }, [children, ariaLabel, ariaLabelledBy]); return ( ( > {({ isIndeterminate }) => ( <> -
+ -
{children}
+ {children != null &&
{children}
} )} diff --git a/packages/ui/src/components/Checkbox/types.ts b/packages/ui/src/components/Checkbox/types.ts index d9b220eb2a..5cdef0e423 100644 --- a/packages/ui/src/components/Checkbox/types.ts +++ b/packages/ui/src/components/Checkbox/types.ts @@ -17,7 +17,7 @@ import type { CheckboxProps as RACheckboxProps } from 'react-aria-components'; /** @public */ export type CheckboxOwnProps = { - children: React.ReactNode; + children?: React.ReactNode; className?: string; }; diff --git a/packages/ui/src/components/Table/Table.module.css b/packages/ui/src/components/Table/Table.module.css index 62723db6c8..953ccbed65 100644 --- a/packages/ui/src/components/Table/Table.module.css +++ b/packages/ui/src/components/Table/Table.module.css @@ -121,6 +121,60 @@ background-color: var(--bui-bg-neutral-1-pressed); } + &[data-on-bg='neutral-1'] { + &:hover { + background-color: var(--bui-bg-neutral-2-hover); + } + + &[data-selected] { + background-color: var(--bui-bg-neutral-2-pressed); + } + + &[data-pressed] { + background-color: var(--bui-bg-neutral-2-pressed); + } + + &[data-disabled] { + background-color: var(--bui-bg-neutral-2-disabled); + } + } + + &[data-on-bg='neutral-2'] { + &:hover { + background-color: var(--bui-bg-neutral-3-hover); + } + + &[data-selected] { + background-color: var(--bui-bg-neutral-3-pressed); + } + + &[data-pressed] { + background-color: var(--bui-bg-neutral-3-pressed); + } + + &[data-disabled] { + background-color: var(--bui-bg-neutral-3-disabled); + } + } + + &[data-on-bg='neutral-3'] { + &:hover { + background-color: var(--bui-bg-neutral-4-hover); + } + + &[data-selected] { + background-color: var(--bui-bg-neutral-4-pressed); + } + + &[data-pressed] { + background-color: var(--bui-bg-neutral-4-pressed); + } + + &[data-disabled] { + background-color: var(--bui-bg-neutral-4-disabled); + } + } + &[data-href], &[data-selection-mode], &[data-react-aria-pressable='true'] { diff --git a/packages/ui/src/components/Table/components/Row.tsx b/packages/ui/src/components/Table/components/Row.tsx index 9d4a15f601..e82bc23e6f 100644 --- a/packages/ui/src/components/Table/components/Row.tsx +++ b/packages/ui/src/components/Table/components/Row.tsx @@ -30,7 +30,7 @@ import { Flex } from '../../Flex'; /** @public */ export function Row(props: RowProps) { - const { ownProps, restProps, analytics } = useDefinition( + const { ownProps, restProps, dataAttributes, analytics } = useDefinition( RowDefinition, props, ); @@ -73,9 +73,7 @@ export function Row(props: RowProps) { {selectionBehavior === 'toggle' && selectionMode === 'multiple' && ( - - <> - + )} @@ -87,6 +85,7 @@ export function Row(props: RowProps) { (props: TableHeaderProps) => { maxWidth={40} className={classes.headSelection} > - - - <> - + + )} diff --git a/packages/ui/src/components/Table/definition.ts b/packages/ui/src/components/Table/definition.ts index 70234f26d0..e2236e21b7 100644 --- a/packages/ui/src/components/Table/definition.ts +++ b/packages/ui/src/components/Table/definition.ts @@ -91,6 +91,7 @@ export const TableBodyDefinition = defineComponent()({ export const RowDefinition = defineComponent()({ styles, analytics: true, + bg: 'consumer', classNames: { root: 'bui-TableRow', cell: 'bui-TableCell', diff --git a/plugins/app-react/src/blueprints/AnalyticsImplementationBlueprint.test.ts b/plugins/app-react/src/blueprints/AnalyticsImplementationBlueprint.test.ts index 06ad9b877e..6d43e0352f 100644 --- a/plugins/app-react/src/blueprints/AnalyticsImplementationBlueprint.test.ts +++ b/plugins/app-react/src/blueprints/AnalyticsImplementationBlueprint.test.ts @@ -39,6 +39,7 @@ describe('AnalyticsBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "analytics", "name": "test", diff --git a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx index a61f583ca8..956e4bbaee 100644 --- a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx @@ -43,6 +43,7 @@ describe('AppRootWrapperBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "app-root-wrapper", "name": undefined, diff --git a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx index cb782409b1..2c74300d9d 100644 --- a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx @@ -86,6 +86,7 @@ describe('NavContentBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "nav-content", "name": undefined, diff --git a/plugins/app-react/src/blueprints/RouterBlueprint.test.tsx b/plugins/app-react/src/blueprints/RouterBlueprint.test.tsx index 0371794fff..899cb34903 100644 --- a/plugins/app-react/src/blueprints/RouterBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/RouterBlueprint.test.tsx @@ -42,6 +42,7 @@ describe('RouterBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "app-router-component", "name": undefined, diff --git a/plugins/app-react/src/blueprints/SignInPageBlueprint.test.tsx b/plugins/app-react/src/blueprints/SignInPageBlueprint.test.tsx index aa1f49c075..d7db57efad 100644 --- a/plugins/app-react/src/blueprints/SignInPageBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/SignInPageBlueprint.test.tsx @@ -38,6 +38,7 @@ describe('SignInPageBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "sign-in-page", "name": undefined, diff --git a/plugins/app-react/src/blueprints/ThemeBlueprint.test.ts b/plugins/app-react/src/blueprints/ThemeBlueprint.test.ts index 5ee66ece67..5ab14e7617 100644 --- a/plugins/app-react/src/blueprints/ThemeBlueprint.test.ts +++ b/plugins/app-react/src/blueprints/ThemeBlueprint.test.ts @@ -39,6 +39,7 @@ describe('ThemeBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "theme", "name": "light", diff --git a/plugins/app-react/src/blueprints/TranslationBlueprint.test.ts b/plugins/app-react/src/blueprints/TranslationBlueprint.test.ts index bf291526ca..47206e91b1 100644 --- a/plugins/app-react/src/blueprints/TranslationBlueprint.test.ts +++ b/plugins/app-react/src/blueprints/TranslationBlueprint.test.ts @@ -54,6 +54,7 @@ describe('TranslationBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "translation", "name": "blob", diff --git a/plugins/app/package.json b/plugins/app/package.json index 0fe0ebfcee..974a39853b 100644 --- a/plugins/app/package.json +++ b/plugins/app/package.json @@ -53,6 +53,7 @@ "dependencies": { "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration-react": "workspace:^", "@backstage/plugin-app-react": "workspace:^", diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 3f14b6d009..4c9b2a8ce4 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -147,7 +147,7 @@ const appPlugin: OverridableFrontendPlugin< ConfigurableExtensionDataRef, { singleton: true; - optional: false; + optional: true; internal: false; } >; diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index 9ae564e06d..af6b134fc1 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -22,6 +22,7 @@ import { JSX, } from 'react'; import { + ExtensionBoundary, coreExtensionData, discoveryApiRef, fetchApiRef, @@ -73,6 +74,7 @@ export const AppRoot = createExtension({ }), children: createExtensionInput([coreExtensionData.reactElement], { singleton: true, + optional: true, }), elements: createExtensionInput([coreExtensionData.reactElement]), wrappers: createExtensionInput( @@ -83,7 +85,7 @@ export const AppRoot = createExtension({ ), }, output: [coreExtensionData.reactElement], - factory({ inputs, apis }) { + factory({ inputs, apis, node }) { if (isProtectedApp()) { const identityApi = apis.get(identityApiRef); if (!identityApi) { @@ -105,9 +107,7 @@ export const AppRoot = createExtension({ }); } - let content: ReactNode = inputs.children.get( - coreExtensionData.reactElement, - ); + let content = inputs.children?.get(coreExtensionData.reactElement); for (const wrapper of inputs.wrappers) { const Component = wrapper.get(AppRootWrapperBlueprint.dataRefs.component); @@ -124,19 +124,21 @@ export const AppRoot = createExtension({ return [ coreExtensionData.reactElement( - - el.get(coreExtensionData.reactElement), - )} - > - {content} - , + + + el.get(coreExtensionData.reactElement), + )} + > + {content} + + , ), ]; }, @@ -253,28 +255,30 @@ export function AppRouter(props: AppRouterProps) { // If the app hasn't configured a sign-in page, we just continue as guest. if (!SignInPageComponent) { - appIdentityProxy.setTarget( - { - getUserId: () => 'guest', - getIdToken: async () => undefined, - getProfile: () => ({ - email: 'guest@example.com', - displayName: 'Guest', - }), - getProfileInfo: async () => ({ - email: 'guest@example.com', - displayName: 'Guest', - }), - getBackstageIdentity: async () => ({ - type: 'user', - userEntityRef: 'user:default/guest', - ownershipEntityRefs: ['user:default/guest'], - }), - getCredentials: async () => ({}), - signOut: async () => {}, - }, - { signOutTargetUrl: basePath || '/' }, - ); + if (!isProtectedApp()) { + appIdentityProxy.setTarget( + { + getUserId: () => 'guest', + getIdToken: async () => undefined, + getProfile: () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getProfileInfo: async () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/guest', + ownershipEntityRefs: ['user:default/guest'], + }), + getCredentials: async () => ({}), + signOut: async () => {}, + }, + { signOutTargetUrl: basePath || '/' }, + ); + } return ( diff --git a/plugins/catalog-react/src/alpha/blueprints/CatalogFilterBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/CatalogFilterBlueprint.test.tsx index 95a1f4c468..fc2bbb993d 100644 --- a/plugins/catalog-react/src/alpha/blueprints/CatalogFilterBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/CatalogFilterBlueprint.test.tsx @@ -43,6 +43,7 @@ describe('CatalogFilterBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "catalog-filter", "name": undefined, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index 0ae880a462..491f3a2dba 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -200,6 +200,7 @@ describe('EntityCardBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "entity-card", "name": "test", diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index 661f501b3f..5bc1aba2c2 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -215,6 +215,7 @@ describe('EntityContentBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "entity-content", "name": "test", diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 6201f32f2d..40f8915917 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -204,6 +204,7 @@ describe('EntityContextMenuItemBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "entity-context-menu-item", "name": "test", diff --git a/plugins/permission-react/package.json b/plugins/permission-react/package.json index eb98150e64..e8fd21305a 100644 --- a/plugins/permission-react/package.json +++ b/plugins/permission-react/package.json @@ -45,6 +45,7 @@ "@backstage/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", + "dataloader": "^2.0.0", "swr": "^2.0.0" }, "devDependencies": { diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.test.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.test.ts new file mode 100644 index 0000000000..42b40e38da --- /dev/null +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { mockApis } from '@backstage/test-utils'; +import { + createPermission, + PermissionClient, +} from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { IdentityPermissionApi } from './IdentityPermissionApi'; + +describe('IdentityPermissionApi', () => { + const permission = createPermission({ + name: 'test.permission', + attributes: {}, + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should batch requests that arrive on the same tick', async () => { + const authorizeSpy = jest + .spyOn(PermissionClient.prototype, 'authorize') + .mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + { result: AuthorizeResult.DENY }, + ]); + const api = IdentityPermissionApi.create({ + config: new ConfigReader({}), + discovery: mockApis.discovery(), + identity: mockApis.identity(), + }); + + const firstRequest = { permission }; + const secondRequest = { permission }; + const [firstResponse, secondResponse] = await Promise.all([ + api.authorize(firstRequest), + api.authorize(secondRequest), + ]); + + expect(firstResponse.result).toBe(AuthorizeResult.ALLOW); + expect(secondResponse.result).toBe(AuthorizeResult.DENY); + expect(authorizeSpy).toHaveBeenCalledTimes(1); + expect(authorizeSpy).toHaveBeenCalledWith( + [firstRequest, secondRequest], + expect.anything(), + ); + }); + + it('should not cache requests across ticks', async () => { + const authorizeSpy = jest + .spyOn(PermissionClient.prototype, 'authorize') + .mockResolvedValue([{ result: AuthorizeResult.ALLOW }]); + const identityApi = mockApis.identity(); + const credentialsSpy = jest + .spyOn(identityApi, 'getCredentials') + .mockResolvedValueOnce({ token: 'first-token' }) + .mockResolvedValueOnce({ token: 'second-token' }); + const api = IdentityPermissionApi.create({ + config: new ConfigReader({}), + discovery: mockApis.discovery(), + identity: identityApi, + }); + + const request = { permission }; + await api.authorize(request); + await api.authorize(request); + + expect(authorizeSpy).toHaveBeenCalledTimes(2); + expect(credentialsSpy).toHaveBeenCalledTimes(2); + expect(authorizeSpy).toHaveBeenNthCalledWith( + 1, + [request], + expect.objectContaining({ token: 'first-token' }), + ); + expect(authorizeSpy).toHaveBeenNthCalledWith( + 2, + [request], + expect.objectContaining({ token: 'second-token' }), + ); + }); +}); diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index dc1fc3b5a3..f7c3c95107 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import DataLoader from 'dataloader'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { PermissionApi } from './PermissionApi'; import { @@ -24,20 +25,30 @@ import { import { Config } from '@backstage/config'; /** - * The default implementation of the PermissionApi, which simply calls the authorize method of the given - * {@link @backstage/plugin-permission-common#PermissionClient}. + * The default implementation of the PermissionApi, which batches calls to + * {@link @backstage/plugin-permission-common#PermissionClient} that are made + * within the same microtask into a single HTTP request. * @public */ export class IdentityPermissionApi implements PermissionApi { - private readonly permissionClient: PermissionClient; - private readonly identityApi: IdentityApi; + private readonly loader: DataLoader< + AuthorizePermissionRequest, + AuthorizePermissionResponse + >; private constructor( permissionClient: PermissionClient, identityApi: IdentityApi, ) { - this.permissionClient = permissionClient; - this.identityApi = identityApi; + this.loader = new DataLoader( + async (requests: readonly AuthorizePermissionRequest[]) => { + const credentials = await identityApi.getCredentials(); + return permissionClient.authorize([...requests], credentials); + }, + { + cache: false, + }, + ); } static create(options: { @@ -50,13 +61,12 @@ export class IdentityPermissionApi implements PermissionApi { return new IdentityPermissionApi(permissionClient, identity); } + async authorize( + request: AuthorizePermissionRequest, + ): Promise; async authorize( request: AuthorizePermissionRequest, ): Promise { - const response = await this.permissionClient.authorize( - [request], - await this.identityApi.getCredentials(), - ); - return response[0]; + return await this.loader.load(request); } } diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index cbe31b02f3..aec6cb61b5 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -154,7 +154,7 @@ export const createGitlabRepoPushAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined; + commitAction?: 'auto' | 'update' | 'create' | 'delete' | undefined; }, { projectid: string; @@ -271,7 +271,7 @@ export const createPublishGitlabMergeRequestAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined; + commitAction?: 'auto' | 'update' | 'create' | 'delete' | 'skip' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index c546f265be..9e01257c5c 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -200,7 +200,9 @@ export type FormFieldExtensionData< }; // @alpha (undocumented) -export const formFieldsApiRef: ApiRef; +export const formFieldsApiRef: ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; // @alpha (undocumented) export type FormValidation = { diff --git a/plugins/scaffolder-react/report.api.md b/plugins/scaffolder-react/report.api.md index daec52a7e3..d40b0278bf 100644 --- a/plugins/scaffolder-react/report.api.md +++ b/plugins/scaffolder-react/report.api.md @@ -208,7 +208,9 @@ export type ReviewStepProps = { export type ScaffolderApi = ScaffolderApi_2; // @public (undocumented) -export const scaffolderApiRef: ApiRef; +export const scaffolderApiRef: ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; // @public @deprecated (undocumented) export type ScaffolderDryRunOptions = ScaffolderDryRunOptions_2; diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 7c96a74c8b..b2cd1bbcbe 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -479,7 +479,9 @@ export const formDecoratorsApi: OverridableExtensionDefinition<{ }>; // @alpha (undocumented) -export const formDecoratorsApiRef: ApiRef; +export const formDecoratorsApiRef: ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; export { formFieldsApiRef }; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index 5c0fffbcc1..17fd25e646 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -528,7 +528,9 @@ export type RouterProps = { export type ScaffolderApi = ScaffolderApi_2; // @public @deprecated (undocumented) -export const scaffolderApiRef: ApiRef; +export const scaffolderApiRef: ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; // @public @deprecated export class ScaffolderClient extends ScaffolderClient_2 {} diff --git a/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx index adfceeec47..2d48454003 100644 --- a/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx +++ b/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx @@ -45,6 +45,7 @@ describe('SearchFilterBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "search-filter", "name": "test", diff --git a/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx index d9f8e97080..d324956068 100644 --- a/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx +++ b/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx @@ -47,6 +47,7 @@ describe('SearchFilterResultTypeBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "search-filter-result-type", "name": "test", diff --git a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx index 112c0a8470..3ef5e2d0e9 100644 --- a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx +++ b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx @@ -60,6 +60,7 @@ describe('SearchResultListItemBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "search-result-list-item", "name": "test", diff --git a/yarn.lock b/yarn.lock index a8c2e6d7b8..9deecea8f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2807,6 +2807,7 @@ __metadata: resolution: "@backstage/cli-defaults@workspace:packages/cli-defaults" dependencies: "@backstage/cli": "workspace:^" + "@backstage/cli-module-actions": "workspace:^" "@backstage/cli-module-auth": "workspace:^" "@backstage/cli-module-build": "workspace:^" "@backstage/cli-module-config": "workspace:^" @@ -2821,6 +2822,20 @@ __metadata: languageName: unknown linkType: soft +"@backstage/cli-module-actions@workspace:^, @backstage/cli-module-actions@workspace:packages/cli-module-actions": + version: 0.0.0-use.local + resolution: "@backstage/cli-module-actions@workspace:packages/cli-module-actions" + dependencies: + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/cli-module-auth": "workspace:^" + "@backstage/cli-node": "workspace:^" + cleye: "npm:^2.3.0" + bin: + cli-module-actions: bin/backstage-cli-module-actions + languageName: unknown + linkType: soft + "@backstage/cli-module-auth@workspace:^, @backstage/cli-module-auth@workspace:packages/cli-module-auth": version: 0.0.0-use.local resolution: "@backstage/cli-module-auth@workspace:packages/cli-module-auth" @@ -2832,7 +2847,6 @@ __metadata: "@types/fs-extra": "npm:^11.0.0" "@types/proper-lockfile": "npm:^4" cleye: "npm:^2.3.0" - cross-fetch: "npm:^4.0.0" fs-extra: "npm:^11.2.0" glob: "npm:^7.1.7" inquirer: "npm:^8.2.0" @@ -3345,6 +3359,7 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" @@ -3614,10 +3629,12 @@ __metadata: "@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/frontend-test-utils": "workspace:^" "@backstage/plugin-app": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" @@ -3653,6 +3670,8 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-app": "workspace:^" "@backstage/plugin-app-react": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@react-hookz/web": "npm:^24.0.0" "@testing-library/jest-dom": "npm:^6.0.0" @@ -3738,6 +3757,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" "@backstage/test-utils": "workspace:^" @@ -3771,6 +3791,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-app": "workspace:^" @@ -4082,6 +4103,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-defaults": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" @@ -6443,6 +6465,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0" "@types/react": "npm:^18.0.0" + dataloader: "npm:^2.0.0" react: "npm:^18.0.2" react-dom: "npm:^18.0.2" react-router-dom: "npm:^6.30.2" @@ -9933,6 +9956,7 @@ __metadata: resolution: "@internal/frontend@workspace:packages/frontend-internal" dependencies: "@backstage/cli": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^"