diff --git a/packages/frontend-app-api/src/extensions/Core.tsx b/packages/frontend-app-api/src/extensions/Core.tsx
index cb93b2888f..4cb21a8702 100644
--- a/packages/frontend-app-api/src/extensions/Core.tsx
+++ b/packages/frontend-app-api/src/extensions/Core.tsx
@@ -17,17 +17,16 @@
import {
coreExtensionData,
createExtension,
+ createExtensionInput,
} from '@backstage/frontend-plugin-api';
export const Core = createExtension({
id: 'core',
at: 'root',
inputs: {
- apis: {
- extensionData: {
- api: coreExtensionData.apiFactory,
- },
- },
+ apis: createExtensionInput({
+ api: coreExtensionData.apiFactory,
+ }),
},
output: {},
factory() {},
diff --git a/packages/frontend-app-api/src/extensions/CoreLayout.tsx b/packages/frontend-app-api/src/extensions/CoreLayout.tsx
index 29680bf23c..0c5c0446fc 100644
--- a/packages/frontend-app-api/src/extensions/CoreLayout.tsx
+++ b/packages/frontend-app-api/src/extensions/CoreLayout.tsx
@@ -18,6 +18,7 @@ import React from 'react';
import {
createExtension,
coreExtensionData,
+ createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { SidebarPage } from '@backstage/core-components';
@@ -25,39 +26,28 @@ export const CoreLayout = createExtension({
id: 'core.layout',
at: 'root',
inputs: {
- nav: {
- extensionData: {
+ nav: createExtensionInput(
+ {
element: coreExtensionData.reactElement,
},
- },
- content: {
- extensionData: {
+ { singleton: true },
+ ),
+ content: createExtensionInput(
+ {
element: coreExtensionData.reactElement,
},
- },
+ { singleton: true },
+ ),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
- // TODO: Support this as part of the core system
- if (inputs.nav.length !== 1) {
- throw Error(
- `Extension 'core.layout' did not receive exactly one 'nav' input, got ${inputs.nav.length}`,
- );
- }
- if (inputs.content.length !== 1) {
- throw Error(
- `Extension 'core.layout' did not receive exactly one 'content' input, got ${inputs.content.length}`,
- );
- }
-
bind({
- // TODO: set base path using the logic from AppRouter
element: (
- {inputs.nav[0].element}
- {inputs.content[0].element}
+ {inputs.nav.element}
+ {inputs.content.element}
),
});
diff --git a/packages/frontend-app-api/src/extensions/CoreNav.tsx b/packages/frontend-app-api/src/extensions/CoreNav.tsx
index da709d1eb3..e66bee8212 100644
--- a/packages/frontend-app-api/src/extensions/CoreNav.tsx
+++ b/packages/frontend-app-api/src/extensions/CoreNav.tsx
@@ -18,6 +18,7 @@ import React from 'react';
import {
createExtension,
coreExtensionData,
+ createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { makeStyles } from '@material-ui/core';
import {
@@ -66,11 +67,9 @@ export const CoreNav = createExtension({
id: 'core.nav',
at: 'core.layout/nav',
inputs: {
- targets: {
- extensionData: {
- path: coreExtensionData.navTarget,
- },
- },
+ items: createExtensionInput({
+ path: coreExtensionData.navTarget,
+ }),
},
output: {
element: coreExtensionData.reactElement,
diff --git a/packages/frontend-app-api/src/extensions/CoreRoutes.tsx b/packages/frontend-app-api/src/extensions/CoreRoutes.tsx
index 5a6cd87bfd..5c79b56f43 100644
--- a/packages/frontend-app-api/src/extensions/CoreRoutes.tsx
+++ b/packages/frontend-app-api/src/extensions/CoreRoutes.tsx
@@ -18,6 +18,7 @@ import React from 'react';
import {
createExtension,
coreExtensionData,
+ createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { useRoutes } from 'react-router-dom';
@@ -25,13 +26,11 @@ export const CoreRoutes = createExtension({
id: 'core.routes',
at: 'core.layout/content',
inputs: {
- routes: {
- extensionData: {
- path: coreExtensionData.routePath,
- ref: coreExtensionData.routeRef,
- element: coreExtensionData.reactElement,
- },
- },
+ routes: createExtensionInput({
+ path: coreExtensionData.routePath,
+ ref: coreExtensionData.routeRef.optional(),
+ element: coreExtensionData.reactElement,
+ }),
},
output: {
element: coreExtensionData.reactElement,
diff --git a/packages/frontend-app-api/src/wiring/createExtensionInstance.test.ts b/packages/frontend-app-api/src/wiring/createExtensionInstance.test.ts
new file mode 100644
index 0000000000..12e109874a
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/createExtensionInstance.test.ts
@@ -0,0 +1,370 @@
+/*
+ * 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 {
+ createExtension,
+ createExtensionDataRef,
+ createExtensionInput,
+ createSchemaFromZod,
+} from '@backstage/frontend-plugin-api';
+import { createExtensionInstance } from './createExtensionInstance';
+
+const testDataRef = createExtensionDataRef('test');
+const otherDataRef = createExtensionDataRef('other');
+const inputMirrorDataRef = createExtensionDataRef('mirror');
+
+const simpleExtension = createExtension({
+ id: 'core.test',
+ at: 'ignored',
+ output: {
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ },
+ configSchema: createSchemaFromZod(z =>
+ z.object({
+ output: z.string().default('test'),
+ other: z.number().optional(),
+ }),
+ ),
+ factory({ bind, config }) {
+ bind({ test: config.output, other: config.other });
+ },
+});
+
+describe('createExtensionInstance', () => {
+ it('should create a simple extension instance', () => {
+ const attachments = new Map();
+ const instance = createExtensionInstance({
+ attachments,
+ config: undefined,
+ extension: simpleExtension,
+ });
+
+ expect(instance.id).toBe('core.test');
+ expect(instance.attachments).toBe(attachments);
+ expect(instance.getData(testDataRef)).toEqual('test');
+ });
+
+ it('should create an extension with different kind of inputs', () => {
+ const attachments = new Map([
+ [
+ 'optionalSingletonPresent',
+ [
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { output: 'optionalSingletonPresent' },
+ extension: simpleExtension,
+ }),
+ ],
+ ],
+ [
+ 'singleton',
+ [
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { output: 'singleton', other: 2 },
+ extension: simpleExtension,
+ }),
+ ],
+ ],
+ [
+ 'many',
+ [
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { output: 'many1' },
+ extension: simpleExtension,
+ }),
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { output: 'many2', other: 3 },
+ extension: simpleExtension,
+ }),
+ ],
+ ],
+ ]);
+ const instance = createExtensionInstance({
+ attachments,
+ config: undefined,
+ extension: createExtension({
+ id: 'core.test',
+ at: 'ignored',
+ inputs: {
+ optionalSingletonPresent: createExtensionInput(
+ {
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ },
+ { singleton: true, optional: true },
+ ),
+ optionalSingletonMissing: createExtensionInput(
+ {
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ },
+ { singleton: true, optional: true },
+ ),
+ singleton: createExtensionInput(
+ {
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ },
+ { singleton: true },
+ ),
+ many: createExtensionInput({
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ }),
+ },
+ output: {
+ inputMirror: inputMirrorDataRef,
+ },
+ factory({ bind, inputs }) {
+ bind({ inputMirror: inputs });
+ },
+ }),
+ });
+
+ expect(instance.id).toBe('core.test');
+ expect(instance.attachments).toBe(attachments);
+ expect(instance.getData(inputMirrorDataRef)).toEqual({
+ optionalSingletonPresent: { test: 'optionalSingletonPresent' },
+ singleton: { test: 'singleton', other: 2 },
+ many: [{ test: 'many1' }, { test: 'many2', other: 3 }],
+ });
+ });
+
+ it('should refuse to create an extension with invalid config', () => {
+ expect(() =>
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { other: 'not-a-number' },
+ extension: simpleExtension,
+ }),
+ ).toThrow(
+ "Invalid configuration for extension 'core.test'; caused by Error: Expected number, received string at 'other'",
+ );
+ });
+
+ it('should forward extension factory errors', () => {
+ expect(() =>
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { other: 'not-a-number' },
+ extension: createExtension({
+ id: 'core.test',
+ at: 'ignored',
+ output: {},
+ factory() {
+ const error = new Error('NOPE');
+ error.name = 'NopeError';
+ throw error;
+ },
+ }),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test'; caused by NopeError: NOPE",
+ );
+ });
+
+ it('should refuse to create an instance with duplicate output', () => {
+ const attachments = new Map();
+ expect(() =>
+ createExtensionInstance({
+ attachments,
+ config: undefined,
+ extension: createExtension({
+ id: 'core.test',
+ at: 'ignored',
+ output: {
+ test1: testDataRef,
+ test2: testDataRef,
+ },
+ factory({ bind }) {
+ bind({ test1: 'test', test2: 'test2' });
+ },
+ }),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', duplicate extension data 'test' received via output 'test2'",
+ );
+ });
+
+ it('should refuse to create an instance with disconnected output data', () => {
+ const attachments = new Map();
+ expect(() =>
+ createExtensionInstance({
+ attachments,
+ config: undefined,
+ extension: createExtension({
+ id: 'core.test',
+ at: 'ignored',
+ output: {
+ test: testDataRef,
+ },
+ factory({ bind }) {
+ bind({ nonexistent: 'test' } as any);
+ },
+ }),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', unknown output provided via 'nonexistent'",
+ );
+ });
+
+ it('should refuse to create an instance with missing required input', () => {
+ expect(() =>
+ createExtensionInstance({
+ attachments: new Map(),
+ config: undefined,
+ extension: createExtension({
+ id: 'core.test',
+ at: 'ignored',
+ inputs: {
+ singleton: createExtensionInput(
+ {
+ test: testDataRef,
+ },
+ { singleton: true },
+ ),
+ },
+ output: {},
+ factory() {},
+ }),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', input 'singleton' is required but was not received",
+ );
+ });
+
+ it('should refuse to create an instance with multiple inputs for required singleton', () => {
+ expect(() =>
+ createExtensionInstance({
+ attachments: new Map([
+ [
+ 'singleton',
+ [
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { output: 'many1' },
+ extension: simpleExtension,
+ }),
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { output: 'many2' },
+ extension: simpleExtension,
+ }),
+ ],
+ ],
+ ]),
+ config: undefined,
+ extension: createExtension({
+ id: 'core.test',
+ at: 'ignored',
+ inputs: {
+ singleton: createExtensionInput(
+ {
+ test: testDataRef,
+ },
+ { singleton: true },
+ ),
+ },
+ output: {},
+ factory() {},
+ }),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', expected exactly one 'singleton' input but received multiple: 'core.test', 'core.test'",
+ );
+ });
+
+ it('should refuse to create an instance with multiple inputs for optional singleton', () => {
+ expect(() =>
+ createExtensionInstance({
+ attachments: new Map([
+ [
+ 'singleton',
+ [
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { output: 'many1' },
+ extension: simpleExtension,
+ }),
+ createExtensionInstance({
+ attachments: new Map(),
+ config: { output: 'many2' },
+ extension: simpleExtension,
+ }),
+ ],
+ ],
+ ]),
+ config: undefined,
+ extension: createExtension({
+ id: 'core.test',
+ at: 'ignored',
+ inputs: {
+ singleton: createExtensionInput(
+ {
+ test: testDataRef,
+ },
+ { singleton: true, optional: true },
+ ),
+ },
+ output: {},
+ factory() {},
+ }),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', expected at most one 'singleton' input but received multiple: 'core.test', 'core.test'",
+ );
+ });
+
+ it('should refuse to create an instance with multiple inputs that did not provide required data', () => {
+ expect(() =>
+ createExtensionInstance({
+ attachments: new Map([
+ [
+ 'singleton',
+ [
+ createExtensionInstance({
+ attachments: new Map(),
+ config: undefined,
+ extension: simpleExtension,
+ }),
+ ],
+ ],
+ ]),
+ config: undefined,
+ extension: createExtension({
+ id: 'core.test',
+ at: 'ignored',
+ inputs: {
+ singleton: createExtensionInput(
+ {
+ other: otherDataRef,
+ },
+ { singleton: true },
+ ),
+ },
+ output: {},
+ factory() {},
+ }),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', input 'singleton' did not receive required extension data 'other' from extension 'core.test'",
+ );
+ });
+});
diff --git a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
index 52afe95121..afa3cbfefc 100644
--- a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
+++ b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
@@ -15,6 +15,8 @@
*/
import {
+ AnyExtensionDataMap,
+ AnyExtensionInputMap,
BackstagePlugin,
Extension,
ExtensionDataRef,
@@ -36,6 +38,56 @@ export interface ExtensionInstance {
readonly attachments: Map;
}
+function resolveInputData(
+ dataMap: AnyExtensionDataMap,
+ attachment: ExtensionInstance,
+ inputName: string,
+) {
+ return mapValues(dataMap, ref => {
+ const value = attachment.getData(ref);
+ if (value === undefined && !ref.config.optional) {
+ throw new Error(
+ `input '${inputName}' did not receive required extension data '${ref.id}' from extension '${attachment.id}'`,
+ );
+ }
+ return value;
+ });
+}
+
+function resolveInputs(
+ inputMap: AnyExtensionInputMap,
+ attachments: Map,
+) {
+ return mapValues(inputMap, (input, inputName) => {
+ const attachedInstances = attachments.get(inputName) ?? [];
+ if (input.config.singleton) {
+ if (attachedInstances.length > 1) {
+ throw Error(
+ `expected ${
+ input.config.optional ? 'at most' : 'exactly'
+ } one '${inputName}' input but received multiple: '${attachedInstances
+ .map(e => e.id)
+ .join("', '")}'`,
+ );
+ } else if (attachedInstances.length === 0) {
+ if (input.config.optional) {
+ return undefined;
+ }
+ throw Error(`input '${inputName}' is required but was not received`);
+ }
+ return resolveInputData(
+ input.extensionData,
+ attachedInstances[0],
+ inputName,
+ );
+ }
+
+ return attachedInstances.map(attachment =>
+ resolveInputData(input.extensionData, attachment, inputName),
+ );
+ });
+}
+
/** @internal */
export function createExtensionInstance(options: {
extension: Extension;
@@ -51,7 +103,7 @@ export function createExtensionInstance(options: {
parsedConfig = extension.configSchema?.parse(config ?? {});
} catch (e) {
throw new Error(
- `Invalid configuration for extension instance '${extension.id}', ${e}`,
+ `Invalid configuration for extension '${extension.id}'; caused by ${e}`,
);
}
@@ -63,26 +115,23 @@ export function createExtensionInstance(options: {
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = extension.output[name];
if (!ref) {
+ throw new Error(`unknown output provided via '${name}'`);
+ }
+ if (extensionData.has(ref.id)) {
throw new Error(
- `Extension instance '${extension.id}' tried to bind unknown output '${name}'`,
+ `duplicate extension data '${ref.id}' received via output '${name}'`,
);
}
extensionData.set(ref.id, output);
}
},
- inputs: mapValues(
- extension.inputs,
- ({ extensionData: pointData }, inputName) => {
- // TODO: validation
- return (attachments.get(inputName) ?? []).map(attachment =>
- mapValues(pointData, ref => attachment.getData(ref)),
- );
- },
- ),
+ inputs: resolveInputs(extension.inputs, attachments),
});
} catch (e) {
throw new Error(
- `Failed to instantiate extension instance '${extension.id}', ${e}`,
+ `Failed to instantiate extension '${extension.id}'${
+ e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
+ }`,
);
}
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index 88a0dc1793..5855976bc4 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -19,7 +19,23 @@ import { ZodTypeDef } from 'zod';
// @public (undocumented)
export type AnyExtensionDataMap = {
- [name in string]: ExtensionDataRef;
+ [name in string]: ExtensionDataRef<
+ unknown,
+ {
+ optional?: true;
+ }
+ >;
+};
+
+// @public (undocumented)
+export type AnyExtensionInputMap = {
+ [inputName in string]: ExtensionInput<
+ AnyExtensionDataMap,
+ {
+ optional: boolean;
+ singleton: boolean;
+ }
+ >;
};
// @public (undocumented)
@@ -60,19 +76,14 @@ export const coreExtensionData: {
// @public (undocumented)
export function createApiExtension<
TConfig extends {},
- TInputs extends Record<
- string,
- {
- extensionData: AnyExtensionDataMap;
- }
- >,
+ TInputs extends AnyExtensionInputMap,
>(
options: (
| {
api: AnyApiRef;
factory: (options: {
config: TConfig;
- inputs: ExtensionDataInputValues;
+ inputs: Expand>;
}) => AnyApiFactory;
}
| {
@@ -87,12 +98,7 @@ export function createApiExtension<
// @public (undocumented)
export function createExtension<
TOutput extends AnyExtensionDataMap,
- TInputs extends Record<
- string,
- {
- extensionData: AnyExtensionDataMap;
- }
- >,
+ TInputs extends AnyExtensionInputMap,
TConfig = never,
>(
options: CreateExtensionOptions,
@@ -103,15 +109,28 @@ export function createExtensionDataRef(
id: string,
): ConfigurableExtensionDataRef;
+// @public (undocumented)
+export function createExtensionInput<
+ TExtensionData extends AnyExtensionDataMap,
+ TConfig extends {
+ singleton?: boolean;
+ optional?: boolean;
+ },
+>(
+ extensionData: TExtensionData,
+ config?: TConfig,
+): ExtensionInput<
+ TExtensionData,
+ {
+ singleton: TConfig['singleton'] extends true ? true : false;
+ optional: TConfig['optional'] extends true ? true : false;
+ }
+>;
+
// @public (undocumented)
export interface CreateExtensionOptions<
TOutput extends AnyExtensionDataMap,
- TInputs extends Record<
- string,
- {
- extensionData: AnyExtensionDataMap;
- }
- >,
+ TInputs extends AnyExtensionInputMap,
TConfig,
> {
// (undocumented)
@@ -123,9 +142,9 @@ export interface CreateExtensionOptions<
// (undocumented)
factory(options: {
source?: BackstagePlugin;
- bind: ExtensionDataBind;
+ bind(values: Expand>): void;
config: TConfig;
- inputs: ExtensionDataInputValues;
+ inputs: Expand>;
}): void;
// (undocumented)
id: string;
@@ -150,12 +169,7 @@ export function createPageExtension<
TConfig extends {
path: string;
},
- TInputs extends Record<
- string,
- {
- extensionData: AnyExtensionDataMap;
- }
- >,
+ TInputs extends AnyExtensionInputMap,
>(
options: (
| {
@@ -172,7 +186,7 @@ export function createPageExtension<
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
- inputs: ExtensionDataInputValues;
+ inputs: Expand>;
}) => Promise;
},
): Extension;
@@ -198,19 +212,17 @@ export interface Extension {
// (undocumented)
factory(options: {
source?: BackstagePlugin;
- bind: ExtensionDataBind;
+ bind(values: ExtensionInputValues): void;
config: TConfig;
- inputs: Record>>;
+ inputs: Record<
+ string,
+ undefined | Record | Array>
+ >;
}): void;
// (undocumented)
id: string;
// (undocumented)
- inputs: Record<
- string,
- {
- extensionData: AnyExtensionDataMap;
- }
- >;
+ inputs: AnyExtensionInputMap;
// (undocumented)
output: AnyExtensionDataMap;
}
@@ -228,48 +240,6 @@ export interface ExtensionBoundaryProps {
source?: BackstagePlugin;
}
-// @public (undocumented)
-export type ExtensionDataBind = (
- values: {
- [DataName in keyof TMap as TMap[DataName]['config'] extends {
- optional: true;
- }
- ? never
- : DataName]: TMap[DataName]['T'];
- } & {
- [DataName in keyof TMap as TMap[DataName]['config'] extends {
- optional: true;
- }
- ? DataName
- : never]?: TMap[DataName]['T'];
- },
-) => void;
-
-// @public (undocumented)
-export type ExtensionDataInputValues<
- TInputs extends {
- [name in string]: {
- extensionData: AnyExtensionDataMap;
- };
- },
-> = {
- [InputName in keyof TInputs]: Array<
- {
- [DataName in keyof TInputs[InputName]['extensionData'] as TInputs[InputName]['extensionData'][DataName]['config'] extends {
- optional: true;
- }
- ? never
- : DataName]: TInputs[InputName]['extensionData'][DataName]['T'];
- } & {
- [DataName in keyof TInputs[InputName]['extensionData'] as TInputs[InputName]['extensionData'][DataName]['config'] extends {
- optional: true;
- }
- ? DataName
- : never]?: TInputs[InputName]['extensionData'][DataName]['T'];
- }
- >;
-};
-
// @public (undocumented)
export type ExtensionDataRef<
TData,
@@ -283,6 +253,52 @@ export type ExtensionDataRef<
$$type: '@backstage/ExtensionDataRef';
};
+// @public
+export type ExtensionDataValues = {
+ [DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
+ optional: true;
+ }
+ ? never
+ : DataName]: TExtensionData[DataName]['T'];
+} & {
+ [DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
+ optional: true;
+ }
+ ? DataName
+ : never]?: TExtensionData[DataName]['T'];
+};
+
+// @public (undocumented)
+export interface ExtensionInput<
+ TExtensionData extends AnyExtensionDataMap,
+ TConfig extends {
+ singleton: boolean;
+ optional: boolean;
+ },
+> {
+ // (undocumented)
+ $$type: '@backstage/ExtensionInput';
+ // (undocumented)
+ config: TConfig;
+ // (undocumented)
+ extensionData: TExtensionData;
+}
+
+// @public
+export type ExtensionInputValues<
+ 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<
+ ExtensionDataValues | undefined
+ >;
+};
+
// @public (undocumented)
export type NavTarget = {
title: string;
diff --git a/packages/frontend-plugin-api/src/extensions/createApiExtension.ts b/packages/frontend-plugin-api/src/extensions/createApiExtension.ts
index f81f07ca72..a142a5af61 100644
--- a/packages/frontend-plugin-api/src/extensions/createApiExtension.ts
+++ b/packages/frontend-plugin-api/src/extensions/createApiExtension.ts
@@ -17,23 +17,23 @@
import { AnyApiFactory, AnyApiRef } from '@backstage/core-plugin-api';
import { PortableSchema } from '../schema';
import {
- AnyExtensionDataMap,
- ExtensionDataInputValues,
+ ExtensionInputValues,
createExtension,
coreExtensionData,
} from '../wiring';
+import { AnyExtensionInputMap, Expand } from '../wiring/createExtension';
/** @public */
export function createApiExtension<
TConfig extends {},
- TInputs extends Record,
+ TInputs extends AnyExtensionInputMap,
>(
options: (
| {
api: AnyApiRef;
factory: (options: {
config: TConfig;
- inputs: ExtensionDataInputValues;
+ inputs: Expand>;
}) => AnyApiFactory;
}
| {
diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx
index 8bca1153bf..7de6ee3253 100644
--- a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx
+++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx
@@ -16,7 +16,7 @@
import React from 'react';
import { PortableSchema } from '../schema';
-import { coreExtensionData } from '../wiring';
+import { coreExtensionData, createExtensionInput } from '../wiring';
import { createPageExtension } from './createPageExtension';
describe('createPageExtension', () => {
@@ -54,9 +54,9 @@ describe('createPageExtension', () => {
disabled: true,
configSchema,
inputs: {
- first: {
- extensionData: { element: coreExtensionData.reactElement },
- },
+ first: createExtensionInput({
+ element: coreExtensionData.reactElement,
+ }),
},
loader: async () => ,
}),
@@ -67,9 +67,9 @@ describe('createPageExtension', () => {
configSchema: expect.anything(),
disabled: true,
inputs: {
- first: {
- extensionData: { element: coreExtensionData.reactElement },
- },
+ first: createExtensionInput({
+ element: coreExtensionData.reactElement,
+ }),
},
output: {
element: expect.anything(),
diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx
index 4c5ff2d8be..5460fb518d 100644
--- a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx
+++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx
@@ -19,12 +19,12 @@ import React from 'react';
import { ExtensionBoundary } from '../components';
import { createSchemaFromZod, PortableSchema } from '../schema';
import {
- AnyExtensionDataMap,
coreExtensionData,
createExtension,
Extension,
- ExtensionDataInputValues,
+ ExtensionInputValues,
} from '../wiring';
+import { AnyExtensionInputMap, Expand } from '../wiring/createExtension';
/**
* Helper for creating extensions for a routable React page component.
@@ -33,7 +33,7 @@ import {
*/
export function createPageExtension<
TConfig extends { path: string },
- TInputs extends Record,
+ TInputs extends AnyExtensionInputMap,
>(
options: (
| {
@@ -50,7 +50,7 @@ export function createPageExtension<
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
- inputs: ExtensionDataInputValues;
+ inputs: Expand>;
}) => Promise;
},
): Extension {
diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts
index a992642d61..6f4a21399d 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts
@@ -16,6 +16,7 @@
import { createExtension } from './createExtension';
import { createExtensionDataRef } from './createExtensionDataRef';
+import { createExtensionInput } from './createExtensionInput';
const stringData = createExtensionDataRef('string');
@@ -95,22 +96,16 @@ describe('createExtension', () => {
id: 'test',
at: 'root',
inputs: {
- mixed: {
- extensionData: {
- required: stringData,
- optional: stringData.optional(),
- },
- },
- onlyRequired: {
- extensionData: {
- required: stringData,
- },
- },
- onlyOptional: {
- extensionData: {
- optional: stringData.optional(),
- },
- },
+ mixed: createExtensionInput({
+ required: stringData,
+ optional: stringData.optional(),
+ }),
+ onlyRequired: createExtensionInput({
+ required: stringData,
+ }),
+ onlyOptional: createExtensionInput({
+ optional: stringData.optional(),
+ }),
},
output: {
foo: stringData,
diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts
index 1a7e02804e..dd365a87d8 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtension.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts
@@ -15,51 +15,68 @@
*/
import { PortableSchema } from '../schema';
+import { ExtensionDataRef } from './createExtensionDataRef';
+import { ExtensionInput } from './createExtensionInput';
import { BackstagePlugin } from './createPlugin';
-import { AnyExtensionDataMap, Extension } from './types';
/** @public */
-export type ExtensionDataInputValues<
- TInputs extends { [name in string]: { extensionData: AnyExtensionDataMap } },
-> = {
- [InputName in keyof TInputs]: Array<
- {
- [DataName in keyof TInputs[InputName]['extensionData'] as TInputs[InputName]['extensionData'][DataName]['config'] extends {
- optional: true;
- }
- ? never
- : DataName]: TInputs[InputName]['extensionData'][DataName]['T'];
- } & {
- [DataName in keyof TInputs[InputName]['extensionData'] as TInputs[InputName]['extensionData'][DataName]['config'] extends {
- optional: true;
- }
- ? DataName
- : never]?: TInputs[InputName]['extensionData'][DataName]['T'];
- }
- >;
+export type AnyExtensionDataMap = {
+ [name in string]: ExtensionDataRef;
};
/** @public */
-export type ExtensionDataBind = (
- values: {
- [DataName in keyof TMap as TMap[DataName]['config'] extends {
- optional: true;
- }
- ? never
- : DataName]: TMap[DataName]['T'];
- } & {
- [DataName in keyof TMap as TMap[DataName]['config'] extends {
- optional: true;
- }
- ? DataName
- : never]?: TMap[DataName]['T'];
- },
-) => void;
+export type AnyExtensionInputMap = {
+ [inputName in string]: ExtensionInput<
+ AnyExtensionDataMap,
+ { optional: boolean; singleton: boolean }
+ >;
+};
+
+// TODO(Rugvip): This might be a quite useful utility type, maybe add to @backstage/types?
+/**
+ * Utility type to expand type aliases into their equivalent type.
+ * @ignore
+ */
+export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never;
+
+/**
+ * Converts an extension data map into the matching concrete data values type.
+ * @public
+ */
+export type ExtensionDataValues = {
+ [DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
+ optional: true;
+ }
+ ? never
+ : DataName]: TExtensionData[DataName]['T'];
+} & {
+ [DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
+ optional: true;
+ }
+ ? DataName
+ : never]?: TExtensionData[DataName]['T'];
+};
+
+/**
+ * Converts an extension input map into the matching concrete input values type.
+ * @public
+ */
+export type ExtensionInputValues<
+ 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<
+ ExtensionDataValues | undefined
+ >;
+};
/** @public */
export interface CreateExtensionOptions<
TOutput extends AnyExtensionDataMap,
- TInputs extends Record,
+ TInputs extends AnyExtensionInputMap,
TConfig,
> {
id: string;
@@ -70,16 +87,36 @@ export interface CreateExtensionOptions<
configSchema?: PortableSchema;
factory(options: {
source?: BackstagePlugin;
- bind: ExtensionDataBind;
+ bind(values: Expand>): void;
config: TConfig;
- inputs: ExtensionDataInputValues;
+ inputs: Expand>;
+ }): void;
+}
+
+/** @public */
+export interface Extension {
+ $$type: '@backstage/Extension';
+ id: string;
+ at: string;
+ disabled: boolean;
+ inputs: AnyExtensionInputMap;
+ output: AnyExtensionDataMap;
+ configSchema?: PortableSchema;
+ factory(options: {
+ source?: BackstagePlugin;
+ bind(values: ExtensionInputValues): void;
+ config: TConfig;
+ inputs: Record<
+ string,
+ undefined | Record | Array>
+ >;
}): void;
}
/** @public */
export function createExtension<
TOutput extends AnyExtensionDataMap,
- TInputs extends Record,
+ TInputs extends AnyExtensionInputMap,
TConfig = never,
>(
options: CreateExtensionOptions,
@@ -94,7 +131,7 @@ export function createExtension<
return options.factory({
bind,
config,
- inputs: inputs as ExtensionDataInputValues,
+ inputs: inputs as Expand>,
});
},
};
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts b/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts
new file mode 100644
index 0000000000..1fd8fea972
--- /dev/null
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 { AnyExtensionDataMap } from './createExtension';
+
+/** @public */
+export interface ExtensionInput<
+ TExtensionData extends AnyExtensionDataMap,
+ TConfig extends { singleton: boolean; optional: boolean },
+> {
+ $$type: '@backstage/ExtensionInput';
+ extensionData: TExtensionData;
+ config: TConfig;
+}
+
+/** @public */
+export function createExtensionInput<
+ TExtensionData extends AnyExtensionDataMap,
+ TConfig extends { singleton?: boolean; optional?: boolean },
+>(
+ extensionData: TExtensionData,
+ config?: TConfig,
+): ExtensionInput<
+ TExtensionData,
+ {
+ singleton: TConfig['singleton'] extends true ? true : false;
+ optional: TConfig['optional'] extends true ? true : false;
+ }
+> {
+ return {
+ $$type: '@backstage/ExtensionInput',
+ extensionData,
+ config: {
+ singleton: Boolean(config?.singleton) as TConfig['singleton'] extends true
+ ? true
+ : false,
+ optional: Boolean(config?.optional) as TConfig['optional'] extends true
+ ? true
+ : false,
+ },
+ };
+}
diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts
index 08d07f2226..1d45c221af 100644
--- a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts
+++ b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts
@@ -24,6 +24,7 @@ import { createExtension } from './createExtension';
import { createExtensionDataRef } from './createExtensionDataRef';
import { coreExtensionData } from './coreExtensionData';
import { MockConfigApi } from '@backstage/test-utils';
+import { createExtensionInput } from './createExtensionInput';
const nameExtensionDataRef = createExtensionDataRef('name');
@@ -70,11 +71,9 @@ const TechDocsPage = createExtension({
id: 'plugin.techdocs.page',
at: 'test.output/names',
inputs: {
- addons: {
- extensionData: {
- name: nameExtensionDataRef,
- },
- },
+ addons: createExtensionInput({
+ name: nameExtensionDataRef,
+ }),
},
output: {
name: nameExtensionDataRef,
@@ -88,11 +87,9 @@ const outputExtension = createExtension({
id: 'test.output',
at: 'root',
inputs: {
- names: {
- extensionData: {
- name: nameExtensionDataRef,
- },
- },
+ names: createExtensionInput({
+ name: nameExtensionDataRef,
+ }),
},
output: {
element: coreExtensionData.reactElement,
diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.ts
index bb372f63c1..bd8e9954ef 100644
--- a/packages/frontend-plugin-api/src/wiring/createPlugin.ts
+++ b/packages/frontend-plugin-api/src/wiring/createPlugin.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { Extension } from './types';
+import { Extension } from './createExtension';
/** @public */
export interface PluginOptions {
diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts
index 4177ffbfcc..b7f0acefd9 100644
--- a/packages/frontend-plugin-api/src/wiring/index.ts
+++ b/packages/frontend-plugin-api/src/wiring/index.ts
@@ -17,10 +17,17 @@
export { coreExtensionData, type NavTarget } from './coreExtensionData';
export {
createExtension,
+ type Extension,
type CreateExtensionOptions,
- type ExtensionDataBind,
- type ExtensionDataInputValues,
+ type ExtensionDataValues,
+ type ExtensionInputValues,
+ type AnyExtensionInputMap,
+ type AnyExtensionDataMap,
} from './createExtension';
+export {
+ createExtensionInput,
+ type ExtensionInput,
+} from './createExtensionInput';
export {
createExtensionDataRef,
type ExtensionDataRef,
@@ -31,4 +38,3 @@ export {
type BackstagePlugin,
type PluginOptions,
} from './createPlugin';
-export type { AnyExtensionDataMap, Extension } from './types';
diff --git a/packages/frontend-plugin-api/src/wiring/types.ts b/packages/frontend-plugin-api/src/wiring/types.ts
deleted file mode 100644
index 46b76e30f4..0000000000
--- a/packages/frontend-plugin-api/src/wiring/types.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * 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 { PortableSchema } from '../schema';
-import { ExtensionDataBind } from './createExtension';
-import { ExtensionDataRef } from './createExtensionDataRef';
-import { BackstagePlugin } from './createPlugin';
-
-/** @public */
-export type AnyExtensionDataMap = {
- [name in string]: ExtensionDataRef;
-};
-
-/** @public */
-export interface Extension {
- $$type: '@backstage/Extension';
- id: string;
- at: string;
- disabled: boolean;
- inputs: Record;
- output: AnyExtensionDataMap;
- configSchema?: PortableSchema;
- factory(options: {
- source?: BackstagePlugin;
- bind: ExtensionDataBind;
- config: TConfig;
- inputs: Record>>;
- }): void;
-}
diff --git a/plugins/graphiql/src/alpha.tsx b/plugins/graphiql/src/alpha.tsx
index 3d1bf839af..9cd956fc1a 100644
--- a/plugins/graphiql/src/alpha.tsx
+++ b/plugins/graphiql/src/alpha.tsx
@@ -19,6 +19,7 @@ import {
createApiExtension,
createExtension,
createExtensionDataRef,
+ createExtensionInput,
createNavItemExtension,
createPageExtension,
createPlugin,
@@ -64,11 +65,9 @@ const endpointDataRef = createExtensionDataRef(
export const graphiqlBrowseApi = createApiExtension({
api: graphQlBrowseApiRef, // apis.plugin.graphiql.browse
inputs: {
- endpoints: {
- extensionData: {
- endpoint: endpointDataRef,
- },
- },
+ endpoints: createExtensionInput({
+ endpoint: endpointDataRef,
+ }),
},
factory({ inputs }) {
return createApiFactory(