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(