diff --git a/.changeset/dry-squids-tap.md b/.changeset/dry-squids-tap.md index 613ec4b7cb..0a95332eed 100644 --- a/.changeset/dry-squids-tap.md +++ b/.changeset/dry-squids-tap.md @@ -11,13 +11,9 @@ This allows the creation of extension instances with the following pattern: const EntityCardBlueprint = createExtensionBlueprint({ kind: 'entity-card', attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, + output: [coreExtensionData.reactElement], factory(params: { text: string }) { - return { - element:

{params.text}

, - }; + return [coreExtensionData.reactElement(

{params.text}

)]; }, }); diff --git a/.changeset/green-planets-reflect.md b/.changeset/green-planets-reflect.md new file mode 100644 index 0000000000..513f0480a5 --- /dev/null +++ b/.changeset/green-planets-reflect.md @@ -0,0 +1,6 @@ +--- +'@backstage/frontend-test-utils': patch +'@backstage/frontend-app-api': patch +--- + +Added support for v2 extensions, which declare their inputs and outputs without using a data map. diff --git a/.changeset/rare-foxes-compete.md b/.changeset/rare-foxes-compete.md new file mode 100644 index 0000000000..64dd9b557e --- /dev/null +++ b/.changeset/rare-foxes-compete.md @@ -0,0 +1,57 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Extensions have been changed to be declared with an array of inputs and outputs, rather than a map of named data refs. This change was made to reduce confusion around the role of the input and output names, as well as enable more powerful APIs for overriding extensions. + +An extension that was previously declared like this: + +```tsx +const exampleExtension = createExtension({ + name: 'example', + inputs: { + items: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + output: { + element: coreExtensionData.reactElement, + }, + factory({ inputs }) { + return { + element: ( +
+ Example + {inputs.items.map(item => { + return
{item.output.element}
; + })} +
+ ), + }; + }, +}); +``` + +Should be migrated to the following: + +```tsx +const exampleExtension = createExtension({ + name: 'example', + inputs: { + items: createExtensionInput([coreExtensionData.reactElement]), + }, + output: [coreExtensionData.reactElement], + factory({ inputs }) { + return [ + coreExtensionData.reactElement( +
+ Example + {inputs.items.map(item => { + return
{item.get(coreExtensionData.reactElement)}
; + })} +
, + ), + ]; + }, +}); +``` diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index a82c8d99ee..b4090185a3 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -17,6 +17,8 @@ import { AppNode, Extension, + ExtensionInput, + ResolvedExtensionInput, createExtension, createExtensionDataRef, createExtensionInput, @@ -38,27 +40,6 @@ const inputMirrorDataRef = createExtensionDataRef().with({ id: 'mirror', }); -const simpleExtension = resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - output: { - test: testDataRef, - other: otherDataRef.optional(), - }, - configSchema: createSchemaFromZod(z => - z.object({ - output: z.string().default('test'), - other: z.number().optional(), - }), - ), - factory({ config }) { - return { test: config.output, other: config.other }; - }, - }), -); - function makeSpec( extension: Extension, spec?: Partial, @@ -100,78 +81,54 @@ function makeInstanceWithId( } describe('instantiateAppNodeTree', () => { - it('should instantiate a single node', () => { - const tree = resolveAppTree('root-node', [ - makeSpec(simpleExtension, { id: 'root-node' }), - ]); - expect(tree.root.instance).not.toBeDefined(); - instantiateAppNodeTree(tree.root); - expect(tree.root.instance).toBeDefined(); - expect(tree.root.instance?.getData(testDataRef)).toBe('test'); - - // Multiple calls should have no effect - instantiateAppNodeTree(tree.root); - expect(tree.root.instance).toBeDefined(); - }); - - it('should not instantiate disabled nodes', () => { - const tree = resolveAppTree('root-node', [ - makeSpec(simpleExtension, { id: 'root-node', disabled: true }), - ]); - expect(tree.root.instance).not.toBeDefined(); - instantiateAppNodeTree(tree.root); - expect(tree.root.instance).not.toBeDefined(); - }); - - it('should instantiate a node with attachments', () => { - const tree = resolveAppTree('root-node', [ - makeSpec( - resolveExtensionDefinition( - createExtension({ - namespace: 'root-node', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - test: createExtensionInput({ test: testDataRef }), - }, - output: { - inputMirror: inputMirrorDataRef, - }, - factory({ inputs }) { - return { inputMirror: inputs }; - }, + describe('v1', () => { + const simpleExtension = resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { + test: testDataRef, + other: otherDataRef.optional(), + }, + configSchema: createSchemaFromZod(z => + z.object({ + output: z.string().default('test'), + other: z.number().optional(), }), ), - ), - makeSpec(simpleExtension, { - id: 'child-node', - attachTo: { id: 'root-node', input: 'test' }, + factory({ config }) { + return { test: config.output, other: config.other }; + }, }), - ]); + ); - const childNode = tree.nodes.get('child-node'); - expect(childNode).toBeDefined(); + it('should instantiate a single node', () => { + const tree = resolveAppTree('root-node', [ + makeSpec(simpleExtension, { id: 'root-node' }), + ]); + expect(tree.root.instance).not.toBeDefined(); + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); + expect(tree.root.instance?.getData(testDataRef)).toBe('test'); - expect(tree.root.instance).not.toBeDefined(); - expect(childNode?.instance).not.toBeDefined(); - instantiateAppNodeTree(tree.root); - expect(tree.root.instance).toBeDefined(); - expect(childNode?.instance).toBeDefined(); - expect(tree.root.instance?.getData(inputMirrorDataRef)).toMatchObject({ - test: [ - { node: { spec: { id: 'child-node' } }, output: { test: 'test' } }, - ], + // Multiple calls should have no effect + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); }); - // Multiple calls should have no effect - instantiateAppNodeTree(tree.root); - expect(tree.root.instance).toBeDefined(); - expect(childNode?.instance).toBeDefined(); - }); + it('should not instantiate disabled nodes', () => { + const tree = resolveAppTree('root-node', [ + makeSpec(simpleExtension, { id: 'root-node', disabled: true }), + ]); + expect(tree.root.instance).not.toBeDefined(); + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).not.toBeDefined(); + }); - it('should not instantiate disabled attachments', () => { - const tree = resolveAppTree('root-node', [ - { - ...makeSpec( + it('should instantiate a node with attachments', () => { + const tree = resolveAppTree('root-node', [ + makeSpec( resolveExtensionDefinition( createExtension({ namespace: 'root-node', @@ -188,437 +145,1061 @@ describe('instantiateAppNodeTree', () => { }), ), ), - }, - { - ...makeSpec(simpleExtension), - id: 'child-node', - // Using an invalid input should not be an error when disabled - attachTo: { id: 'root-node', input: 'invalid' }, - disabled: true, - }, - ]); + makeSpec(simpleExtension, { + id: 'child-node', + attachTo: { id: 'root-node', input: 'test' }, + }), + ]); - const childNode = tree.nodes.get('child-node'); - expect(childNode).toBeDefined(); + const childNode = tree.nodes.get('child-node'); + expect(childNode).toBeDefined(); - expect(tree.root.instance).not.toBeDefined(); - expect(childNode?.instance).not.toBeDefined(); - instantiateAppNodeTree(tree.root); - expect(tree.root.instance).toBeDefined(); - expect(childNode?.instance).not.toBeDefined(); - expect(tree.root.instance?.getData(inputMirrorDataRef)).toEqual({ - test: [], - }); - }); -}); + expect(tree.root.instance).not.toBeDefined(); + expect(childNode?.instance).not.toBeDefined(); + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); + expect(childNode?.instance).toBeDefined(); + expect(tree.root.instance?.getData(inputMirrorDataRef)).toMatchObject({ + test: [ + { node: { spec: { id: 'child-node' } }, output: { test: 'test' } }, + ], + }); -describe('createAppNodeInstance', () => { - it('should create a simple extension instance', () => { - const attachments = new Map(); - const instance = createAppNodeInstance({ - node: makeNode(simpleExtension), - attachments, + // Multiple calls should have no effect + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); + expect(childNode?.instance).toBeDefined(); }); - expect(Array.from(instance.getDataRefs())).toEqual([ - testDataRef, - otherDataRef.optional(), - ]); - expect(instance.getData(testDataRef)).toEqual('test'); - }); - - it('should create an extension with different kind of inputs', () => { - const attachments = new Map([ - [ - 'optionalSingletonPresent', - [ - makeInstanceWithId(simpleExtension, { - output: 'optionalSingletonPresent', - }), - ], - ], - [ - 'singleton', - [ - makeInstanceWithId(simpleExtension, { - output: 'singleton', - other: 2, - }), - ], - ], - [ - 'many', - [ - makeInstanceWithId(simpleExtension, { output: 'many1' }), - makeInstanceWithId(simpleExtension, { output: 'many2', other: 3 }), - ], - ], - ]); - const instance = createAppNodeInstance({ - attachments, - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'test', - attachTo: { id: 'ignored', input: '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({ inputs }) { - return { inputMirror: inputs }; - }, - }), - ), - ), - }); - - expect(Array.from(instance.getDataRefs())).toEqual([inputMirrorDataRef]); - expect(instance.getData(inputMirrorDataRef)).toMatchObject({ - optionalSingletonPresent: { - node: { spec: { id: 'app/test' } }, - output: { test: 'optionalSingletonPresent' }, - }, - singleton: { - node: { spec: { id: 'app/test' } }, - output: { test: 'singleton', other: 2 }, - }, - many: [ - { node: { spec: { id: 'app/test' } }, output: { test: 'many1' } }, + it('should not instantiate disabled attachments', () => { + const tree = resolveAppTree('root-node', [ { - node: { spec: { id: 'app/test' } }, - output: { test: 'many2', other: 3 }, + ...makeSpec( + resolveExtensionDefinition( + createExtension({ + namespace: 'root-node', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + test: createExtensionInput({ test: testDataRef }), + }, + output: { + inputMirror: inputMirrorDataRef, + }, + factory({ inputs }) { + return { inputMirror: inputs }; + }, + }), + ), + ), }, - ], + { + ...makeSpec(simpleExtension), + id: 'child-node', + // Using an invalid input should not be an error when disabled + attachTo: { id: 'root-node', input: 'invalid' }, + disabled: true, + }, + ]); + + const childNode = tree.nodes.get('child-node'); + expect(childNode).toBeDefined(); + + expect(tree.root.instance).not.toBeDefined(); + expect(childNode?.instance).not.toBeDefined(); + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); + expect(childNode?.instance).not.toBeDefined(); + expect(tree.root.instance?.getData(inputMirrorDataRef)).toEqual({ + test: [], + }); + }); + + describe('createAppNodeInstance', () => { + it('should create a simple extension instance', () => { + const attachments = new Map(); + const instance = createAppNodeInstance({ + node: makeNode(simpleExtension), + attachments, + }); + + expect(Array.from(instance.getDataRefs())).toEqual([ + testDataRef, + otherDataRef.optional(), + ]); + expect(instance.getData(testDataRef)).toEqual('test'); + }); + + it('should create an extension with different kind of inputs', () => { + const attachments = new Map([ + [ + 'optionalSingletonPresent', + [ + makeInstanceWithId(simpleExtension, { + output: 'optionalSingletonPresent', + }), + ], + ], + [ + 'singleton', + [ + makeInstanceWithId(simpleExtension, { + output: 'singleton', + other: 2, + }), + ], + ], + [ + 'many', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { + output: 'many2', + other: 3, + }), + ], + ], + ]); + const instance = createAppNodeInstance({ + attachments, + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: '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({ inputs }) { + return { inputMirror: inputs }; + }, + }), + ), + ), + }); + + expect(Array.from(instance.getDataRefs())).toEqual([ + inputMirrorDataRef, + ]); + expect(instance.getData(inputMirrorDataRef)).toMatchObject({ + optionalSingletonPresent: { + node: { spec: { id: 'app/test' } }, + output: { test: 'optionalSingletonPresent' }, + }, + singleton: { + node: { spec: { id: 'app/test' } }, + output: { test: 'singleton', other: 2 }, + }, + many: [ + { node: { spec: { id: 'app/test' } }, output: { test: 'many1' } }, + { + node: { spec: { id: 'app/test' } }, + output: { test: 'many2', other: 3 }, + }, + ], + }); + }); + + it('should refuse to create an extension with invalid config', () => { + expect(() => + createAppNodeInstance({ + node: makeNode(simpleExtension, { + config: { other: 'not-a-number' }, + }), + attachments: new Map(), + }), + ).toThrow( + "Invalid configuration for extension 'app/test'; caused by Error: Expected number, received string at 'other'", + ); + }); + + it('should forward extension factory errors', () => { + expect(() => + createAppNodeInstance({ + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: {}, + factory() { + const error = new Error('NOPE'); + error.name = 'NopeError'; + throw error; + }, + }), + ), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'app/test'; caused by NopeError: NOPE", + ); + }); + + it('should refuse to create an instance with duplicate output', () => { + expect(() => + createAppNodeInstance({ + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { + test1: testDataRef, + test2: testDataRef, + }, + factory({}) { + return { test1: 'test', test2: 'test2' }; + }, + }), + ), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', duplicate extension data 'test' received via output 'test2'", + ); + }); + + it('should refuse to create an instance with disconnected output data', () => { + expect(() => + createAppNodeInstance({ + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { + test: testDataRef, + }, + factory({}) { + return { nonexistent: 'test' } as any; + }, + }), + ), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', unknown output provided via 'nonexistent'", + ); + }); + + it('should refuse to create an instance with missing required input', () => { + expect(() => + createAppNodeInstance({ + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput( + { + test: testDataRef, + }, + { singleton: true }, + ), + }, + output: {}, + factory: () => ({}), + }), + ), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', input 'singleton' is required but was not received", + ); + }); + + it('should warn when creating an instance with undeclared inputs', () => { + const { warn } = withLogCollector(['warn'], () => + createAppNodeInstance({ + attachments: new Map([ + [ + 'declared', + [ + makeInstanceWithId(simpleExtension, { + output: 'many1', + }), + ], + ], + [ + 'undeclared', + [ + makeInstanceWithId(simpleExtension, { + output: 'many1', + }), + ], + ], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'parent', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + declared: createExtensionInput({ + test: testDataRef, + }), + }, + output: {}, + factory: () => ({}), + }), + ), + ), + }), + ); + + expect(warn).toEqual([ + "The extension 'app/test' is attached to the input 'undeclared' of the extension 'app/parent', but it has no such input (candidates are 'declared')", + ]); + }); + + it('should refuse to create an instance with multiple undeclared inputs', () => { + const { warn } = withLogCollector(['warn'], () => + createAppNodeInstance({ + attachments: new Map([ + [ + 'undeclared1', + [makeInstanceWithId(simpleExtension, { output: 'many1' })], + ], + [ + 'undeclared2', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many1' }), + ], + ], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'parent', + attachTo: { id: 'ignored', input: 'ignored' }, + output: {}, + factory: () => ({}), + }), + ), + ), + }), + ); + + expect(warn).toEqual([ + "The extension 'app/test' is attached to the input 'undeclared1' of the extension 'app/parent', but it has no inputs", + "The extensions 'app/test', 'app/test' are attached to the input 'undeclared2' of the extension 'app/parent', but it has no inputs", + ]); + }); + + it('should refuse to create an instance with multiple inputs for required singleton', () => { + expect(() => + createAppNodeInstance({ + attachments: new Map([ + [ + 'singleton', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many2' }), + ], + ], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput( + { + test: testDataRef, + }, + { singleton: true }, + ), + }, + output: {}, + factory: () => ({}), + }), + ), + ), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', expected exactly one 'singleton' input but received multiple: 'app/test', 'app/test'", + ); + }); + + it('should refuse to create an instance with multiple inputs for optional singleton', () => { + expect(() => + createAppNodeInstance({ + attachments: new Map([ + [ + 'singleton', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many2' }), + ], + ], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput( + { + test: testDataRef, + }, + { singleton: true, optional: true }, + ), + }, + output: {}, + factory: () => ({}), + }), + ), + ), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', expected at most one 'singleton' input but received multiple: 'app/test', 'app/test'", + ); + }); + + it('should refuse to create an instance with multiple inputs that did not provide required data', () => { + expect(() => + createAppNodeInstance({ + attachments: new Map([ + ['singleton', [makeInstanceWithId(simpleExtension, undefined)]], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput( + { + other: otherDataRef, + }, + { singleton: true }, + ), + }, + output: {}, + factory: () => ({}), + }), + ), + ), + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Failed to instantiate extension 'app/test', extension 'app/test' could not be attached because its output data ('test', 'other') does not match what the input 'singleton' requires ('other')"`, + ); + }); }); }); - it('should refuse to create an extension with invalid config', () => { - expect(() => - createAppNodeInstance({ - node: makeNode(simpleExtension, { config: { other: 'not-a-number' } }), - attachments: new Map(), - }), - ).toThrow( - "Invalid configuration for extension 'app/test'; caused by Error: Expected number, received string at 'other'", - ); - }); - - it('should forward extension factory errors', () => { - expect(() => - createAppNodeInstance({ - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - output: {}, - factory() { - const error = new Error('NOPE'); - error.name = 'NopeError'; - throw error; - }, - }), - ), + describe('v2', () => { + const simpleExtension = resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: [testDataRef, otherDataRef.optional()], + configSchema: createSchemaFromZod(z => + z.object({ + output: z.string().default('test'), + other: z.number().optional(), + }), ), - attachments: new Map(), + factory({ config }) { + return [ + testDataRef(config.output), + ...(config.other ? [otherDataRef(config.other)] : []), + ]; + }, }), - ).toThrow( - "Failed to instantiate extension 'app/test'; caused by NopeError: NOPE", ); - }); - it('should refuse to create an instance with duplicate output', () => { - expect(() => - createAppNodeInstance({ - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - output: { - test1: testDataRef, - test2: testDataRef, - }, - factory({}) { - return { test1: 'test', test2: 'test2' }; - }, - }), - ), - ), - attachments: new Map(), - }), - ).toThrow( - "Failed to instantiate extension 'app/test', duplicate extension data 'test' received via output 'test2'", - ); - }); - - it('should refuse to create an instance with disconnected output data', () => { - expect(() => - createAppNodeInstance({ - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - output: { - test: testDataRef, - }, - factory({}) { - return { nonexistent: 'test' } as any; - }, - }), - ), - ), - attachments: new Map(), - }), - ).toThrow( - "Failed to instantiate extension 'app/test', unknown output provided via 'nonexistent'", - ); - }); - - it('should refuse to create an instance with missing required input', () => { - expect(() => - createAppNodeInstance({ - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - singleton: createExtensionInput( - { - test: testDataRef, + function mirrorInputs(ctx: { + inputs: { + [name in string]: + | undefined + | ResolvedExtensionInput< + ExtensionInput + > + | Array< + ResolvedExtensionInput< + ExtensionInput + > + >; + }; + }) { + return [ + inputMirrorDataRef( + Object.fromEntries( + Object.entries(ctx.inputs).map(([k, v]) => [ + k, + Array.isArray(v) + ? v.map(vi => ({ + node: vi.node, + test: vi.get(testDataRef), + other: vi.get(otherDataRef), + })) + : { + node: v?.node, + test: v?.get(testDataRef), + other: v?.get(otherDataRef), }, - { singleton: true }, - ), + ]), + ), + ), + ]; + } + + it('should instantiate a single node', () => { + const tree = resolveAppTree('root-node', [ + makeSpec(simpleExtension, { id: 'root-node' }), + ]); + expect(tree.root.instance).not.toBeDefined(); + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); + expect(tree.root.instance?.getData(testDataRef)).toBe('test'); + + // Multiple calls should have no effect + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); + }); + + it('should not instantiate disabled nodes', () => { + const tree = resolveAppTree('root-node', [ + makeSpec(simpleExtension, { id: 'root-node', disabled: true }), + ]); + expect(tree.root.instance).not.toBeDefined(); + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).not.toBeDefined(); + }); + + it('should instantiate a node with attachments', () => { + const tree = resolveAppTree('root-node', [ + makeSpec( + resolveExtensionDefinition( + createExtension({ + namespace: 'root-node', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + test: createExtensionInput([testDataRef]), }, - output: {}, - factory: () => ({}), + output: [inputMirrorDataRef], + factory: mirrorInputs, }), ), ), - attachments: new Map(), - }), - ).toThrow( - "Failed to instantiate extension 'app/test', input 'singleton' is required but was not received", - ); - }); + makeSpec(simpleExtension, { + id: 'child-node', + attachTo: { id: 'root-node', input: 'test' }, + }), + ]); - it('should warn when creating an instance with undeclared inputs', () => { - const { warn } = withLogCollector(['warn'], () => - createAppNodeInstance({ - attachments: new Map([ + const childNode = tree.nodes.get('child-node'); + expect(childNode).toBeDefined(); + + expect(tree.root.instance).not.toBeDefined(); + expect(childNode?.instance).not.toBeDefined(); + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); + expect(childNode?.instance).toBeDefined(); + expect(tree.root.instance?.getData(inputMirrorDataRef)).toMatchObject({ + test: [{ node: { spec: { id: 'child-node' } }, test: 'test' }], + }); + + // Multiple calls should have no effect + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); + expect(childNode?.instance).toBeDefined(); + }); + + it('should not instantiate disabled attachments', () => { + const tree = resolveAppTree('root-node', [ + { + ...makeSpec( + resolveExtensionDefinition( + createExtension({ + namespace: 'root-node', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + test: createExtensionInput([testDataRef]), + }, + output: [inputMirrorDataRef], + factory: mirrorInputs, + }), + ), + ), + }, + { + ...makeSpec(simpleExtension), + id: 'child-node', + // Using an invalid input should not be an error when disabled + attachTo: { id: 'root-node', input: 'invalid' }, + disabled: true, + }, + ]); + + const childNode = tree.nodes.get('child-node'); + expect(childNode).toBeDefined(); + + expect(tree.root.instance).not.toBeDefined(); + expect(childNode?.instance).not.toBeDefined(); + instantiateAppNodeTree(tree.root); + expect(tree.root.instance).toBeDefined(); + expect(childNode?.instance).not.toBeDefined(); + expect(tree.root.instance?.getData(inputMirrorDataRef)).toEqual({ + test: [], + }); + }); + + describe('createAppNodeInstance', () => { + it('should create a simple extension instance', () => { + const attachments = new Map(); + const instance = createAppNodeInstance({ + node: makeNode(simpleExtension), + attachments, + }); + + expect(Array.from(instance.getDataRefs())).toEqual([testDataRef]); + expect(instance.getData(testDataRef)).toEqual('test'); + }); + + it('should create an extension with different kind of inputs', () => { + const attachments = new Map([ [ - 'declared', + 'optionalSingletonPresent', [ makeInstanceWithId(simpleExtension, { - output: 'many1', + output: 'optionalSingletonPresent', }), ], ], [ - 'undeclared', + 'singleton', [ makeInstanceWithId(simpleExtension, { - output: 'many1', + output: 'singleton', + other: 2, }), ], ], - ]), - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'parent', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - declared: createExtensionInput({ - test: testDataRef, + [ + 'many', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { + output: 'many2', + other: 3, + }), + ], + ], + ]); + + const instance = createAppNodeInstance({ + attachments, + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + optionalSingletonPresent: createExtensionInput( + [testDataRef, otherDataRef.optional()], + { singleton: true, optional: true }, + ), + optionalSingletonMissing: createExtensionInput( + [testDataRef, otherDataRef.optional()], + { singleton: true, optional: true }, + ), + singleton: createExtensionInput( + [testDataRef, otherDataRef.optional()], + { singleton: true }, + ), + many: createExtensionInput([ + testDataRef, + otherDataRef.optional(), + ]), + }, + output: [inputMirrorDataRef], + factory: mirrorInputs, + }), + ), + ), + }); + + expect(Array.from(instance.getDataRefs())).toEqual([ + inputMirrorDataRef, + ]); + expect(instance.getData(inputMirrorDataRef)).toMatchObject({ + optionalSingletonPresent: { + node: { spec: { id: 'app/test' } }, + test: 'optionalSingletonPresent', + }, + singleton: { + node: { spec: { id: 'app/test' } }, + test: 'singleton', + other: 2, + }, + many: [ + { node: { spec: { id: 'app/test' } }, test: 'many1' }, + { + node: { spec: { id: 'app/test' } }, + test: 'many2', + other: 3, + }, + ], + }); + }); + + it('should refuse to create an extension with invalid config', () => { + expect(() => + createAppNodeInstance({ + node: makeNode(simpleExtension, { + config: { other: 'not-a-number' }, + }), + attachments: new Map(), + }), + ).toThrow( + "Invalid configuration for extension 'app/test'; caused by Error: Expected number, received string at 'other'", + ); + }); + + it('should forward extension factory errors', () => { + expect(() => + createAppNodeInstance({ + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: [], + factory() { + const error = new Error('NOPE'); + error.name = 'NopeError'; + throw error; + }, }), - }, - output: {}, - factory: () => ({}), - }), - ), - ), - }), - ); + ), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'app/test'; caused by NopeError: NOPE", + ); + }); - expect(warn).toEqual([ - "The extension 'app/test' is attached to the input 'undeclared' of the extension 'app/parent', but it has no such input (candidates are 'declared')", - ]); - }); - - it('should refuse to create an instance with multiple undeclared inputs', () => { - const { warn } = withLogCollector(['warn'], () => - createAppNodeInstance({ - attachments: new Map([ - [ - 'undeclared1', - [makeInstanceWithId(simpleExtension, { output: 'many1' })], - ], - [ - 'undeclared2', - [ - makeInstanceWithId(simpleExtension, { output: 'many1' }), - makeInstanceWithId(simpleExtension, { output: 'many1' }), - ], - ], - ]), - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'parent', - attachTo: { id: 'ignored', input: 'ignored' }, - output: {}, - factory: () => ({}), - }), - ), - ), - }), - ); - - expect(warn).toEqual([ - "The extension 'app/test' is attached to the input 'undeclared1' of the extension 'app/parent', but it has no inputs", - "The extensions 'app/test', 'app/test' are attached to the input 'undeclared2' of the extension 'app/parent', but it has no inputs", - ]); - }); - - it('should refuse to create an instance with multiple inputs for required singleton', () => { - expect(() => - createAppNodeInstance({ - attachments: new Map([ - [ - 'singleton', - [ - makeInstanceWithId(simpleExtension, { output: 'many1' }), - makeInstanceWithId(simpleExtension, { output: 'many2' }), - ], - ], - ]), - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - singleton: createExtensionInput( - { - test: testDataRef, + it('should refuse to create an instance with duplicate output', () => { + expect(() => + createAppNodeInstance({ + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: [testDataRef, testDataRef], + factory({}) { + return [testDataRef('test'), testDataRef('test2')]; }, - { singleton: true }, - ), - }, - output: {}, - factory: () => ({}), - }), - ), - ), - }), - ).toThrow( - "Failed to instantiate extension 'app/test', expected exactly one 'singleton' input but received multiple: 'app/test', 'app/test'", - ); - }); + }), + ), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', duplicate extension data output 'test'", + ); + }); - it('should refuse to create an instance with multiple inputs for optional singleton', () => { - expect(() => - createAppNodeInstance({ - attachments: new Map([ - [ - 'singleton', - [ - makeInstanceWithId(simpleExtension, { output: 'many1' }), - makeInstanceWithId(simpleExtension, { output: 'many2' }), - ], - ], - ]), - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - singleton: createExtensionInput( - { - test: testDataRef, + it('should refuse to create an instance without required', () => { + expect(() => + createAppNodeInstance({ + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: [testDataRef], + factory({}) { + return [] as any; }, - { singleton: true, optional: true }, - ), - }, - output: {}, - factory: () => ({}), - }), - ), - ), - }), - ).toThrow( - "Failed to instantiate extension 'app/test', expected at most one 'singleton' input but received multiple: 'app/test', 'app/test'", - ); - }); + }), + ), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', missing required extension data output 'test'", + ); + }); - it('should refuse to create an instance with multiple inputs that did not provide required data', () => { - expect(() => - createAppNodeInstance({ - attachments: new Map([ - ['singleton', [makeInstanceWithId(simpleExtension, undefined)]], - ]), - node: makeNode( - resolveExtensionDefinition( - createExtension({ - namespace: 'app', - name: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - singleton: createExtensionInput( - { - other: otherDataRef, + it('should refuse to create an instance with unknown output data', () => { + expect(() => + createAppNodeInstance({ + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: [], + factory({}) { + return [testDataRef('test')] as any; }, - { singleton: true }, - ), - }, - output: {}, - factory: () => ({}), - }), - ), - ), - }), - ).toThrowErrorMatchingInlineSnapshot( - `"Failed to instantiate extension 'app/test', extension 'app/test' could not be attached because its output data ('test', 'other') does not match what the input 'singleton' requires ('other')"`, - ); + }), + ), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', unexpected output 'test'", + ); + }); + + it('should refuse to create an instance with missing required input', () => { + expect(() => + createAppNodeInstance({ + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput([testDataRef], { + singleton: true, + }), + }, + output: [], + factory: () => [], + }), + ), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', input 'singleton' is required but was not received", + ); + }); + + it('should warn when creating an instance with undeclared inputs', () => { + const { warn } = withLogCollector(['warn'], () => + createAppNodeInstance({ + attachments: new Map([ + [ + 'declared', + [ + makeInstanceWithId(simpleExtension, { + output: 'many1', + }), + ], + ], + [ + 'undeclared', + [ + makeInstanceWithId(simpleExtension, { + output: 'many1', + }), + ], + ], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'parent', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + declared: createExtensionInput([testDataRef]), + }, + output: [], + factory: () => [], + }), + ), + ), + }), + ); + + expect(warn).toEqual([ + "The extension 'app/test' is attached to the input 'undeclared' of the extension 'app/parent', but it has no such input (candidates are 'declared')", + ]); + }); + + it('should refuse to create an instance with multiple undeclared inputs', () => { + const { warn } = withLogCollector(['warn'], () => + createAppNodeInstance({ + attachments: new Map([ + [ + 'undeclared1', + [makeInstanceWithId(simpleExtension, { output: 'many1' })], + ], + [ + 'undeclared2', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many1' }), + ], + ], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'parent', + attachTo: { id: 'ignored', input: 'ignored' }, + output: [], + factory: () => [], + }), + ), + ), + }), + ); + + expect(warn).toEqual([ + "The extension 'app/test' is attached to the input 'undeclared1' of the extension 'app/parent', but it has no inputs", + "The extensions 'app/test', 'app/test' are attached to the input 'undeclared2' of the extension 'app/parent', but it has no inputs", + ]); + }); + + it('should refuse to create an instance with multiple inputs for required singleton', () => { + expect(() => + createAppNodeInstance({ + attachments: new Map([ + [ + 'singleton', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many2' }), + ], + ], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput([testDataRef], { + singleton: true, + }), + }, + output: [], + factory: () => [], + }), + ), + ), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', expected exactly one 'singleton' input but received multiple: 'app/test', 'app/test'", + ); + }); + + it('should refuse to create an instance with multiple inputs for optional singleton', () => { + expect(() => + createAppNodeInstance({ + attachments: new Map([ + [ + 'singleton', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many2' }), + ], + ], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput([testDataRef], { + singleton: true, + optional: true, + }), + }, + output: [], + factory: () => [], + }), + ), + ), + }), + ).toThrow( + "Failed to instantiate extension 'app/test', expected at most one 'singleton' input but received multiple: 'app/test', 'app/test'", + ); + }); + + it('should refuse to create an instance with multiple inputs that did not provide required data', () => { + expect(() => + createAppNodeInstance({ + attachments: new Map([ + ['singleton', [makeInstanceWithId(simpleExtension, undefined)]], + ]), + node: makeNode( + resolveExtensionDefinition( + createExtension({ + namespace: 'app', + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput([otherDataRef], { + singleton: true, + }), + }, + output: [], + factory: () => [], + }), + ), + ), + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Failed to instantiate extension 'app/test', extension 'app/test' could not be attached because its output data ('test') does not match what the input 'singleton' requires ('other')"`, + ); + }); + }); }); }); diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 63eca749b7..65626e0be1 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -16,8 +16,11 @@ import { AnyExtensionDataMap, + AnyExtensionDataRef, AnyExtensionInputMap, + ExtensionDataContainer, ExtensionDataRef, + ExtensionInput, ResolvedExtensionInputs, } from '@backstage/frontend-plugin-api'; import mapValues from 'lodash/mapValues'; @@ -29,7 +32,7 @@ type Mutable = { -readonly [P in keyof T]: T[P]; }; -function resolveInputData( +function resolveInputDataMap( dataMap: AnyExtensionDataMap, attachment: AppNode, inputName: string, @@ -54,35 +57,76 @@ function resolveInputData( }); } -function resolveInputs( +function resolveInputDataContainer( + extensionData: Array, + attachment: AppNode, + inputName: string, +): { node: AppNode } & ExtensionDataContainer { + const dataMap = new Map(); + + for (const ref of extensionData) { + if (dataMap.has(ref.id)) { + throw new Error(`Unexpected duplicate input data '${ref.id}'`); + } + const value = attachment.instance?.getData(ref); + if (value === undefined && !ref.config.optional) { + const expected = extensionData + .filter(r => !r.config.optional) + .map(r => `'${r.id}'`) + .join(', '); + + const provided = [...(attachment.instance?.getDataRefs() ?? [])] + .map(r => `'${r.id}'`) + .join(', '); + + throw new Error( + `extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`, + ); + } + + dataMap.set(ref.id, value); + } + + return { + node: attachment, + get(ref) { + return dataMap.get(ref.id); + }, + } as { node: AppNode } & ExtensionDataContainer; +} + +function reportUndeclaredAttachments( id: string, - inputMap: AnyExtensionInputMap, + inputMap: { [name in string]: unknown }, attachments: ReadonlyMap, -): ResolvedExtensionInputs { +) { const undeclaredAttachments = Array.from(attachments.entries()).filter( ([inputName]) => inputMap[inputName] === undefined, ); - if (process.env.NODE_ENV !== 'production') { - const inputNames = Object.keys(inputMap); + const inputNames = Object.keys(inputMap); - for (const [name, nodes] of undeclaredAttachments) { - const pl = nodes.length > 1; - // eslint-disable-next-line no-console - console.warn( - [ - `The extension${pl ? 's' : ''} '${nodes - .map(n => n.spec.id) - .join("', '")}' ${pl ? 'are' : 'is'}`, - `attached to the input '${name}' of the extension '${id}', but it`, - inputNames.length === 0 - ? 'has no inputs' - : `has no such input (candidates are '${inputNames.join("', '")}')`, - ].join(' '), - ); - } + for (const [name, nodes] of undeclaredAttachments) { + const pl = nodes.length > 1; + // eslint-disable-next-line no-console + console.warn( + [ + `The extension${pl ? 's' : ''} '${nodes + .map(n => n.spec.id) + .join("', '")}' ${pl ? 'are' : 'is'}`, + `attached to the input '${name}' of the extension '${id}', but it`, + inputNames.length === 0 + ? 'has no inputs' + : `has no such input (candidates are '${inputNames.join("', '")}')`, + ].join(' '), + ); } +} +function resolveV1Inputs( + inputMap: AnyExtensionInputMap, + attachments: ReadonlyMap, +): ResolvedExtensionInputs { return mapValues(inputMap, (input, inputName) => { const attachedNodes = attachments.get(inputName) ?? []; @@ -104,7 +148,7 @@ function resolveInputs( } return { node: attachedNodes[0], - output: resolveInputData( + output: resolveInputDataMap( input.extensionData, attachedNodes[0], inputName, @@ -114,11 +158,62 @@ function resolveInputs( return attachedNodes.map(attachment => ({ node: attachment, - output: resolveInputData(input.extensionData, attachment, inputName), + output: resolveInputDataMap(input.extensionData, attachment, inputName), })); }) as ResolvedExtensionInputs; } +function resolveV2Inputs( + inputMap: { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, + attachments: ReadonlyMap, +): ResolvedExtensionInputs<{ + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; +}> { + return mapValues(inputMap, (input, inputName) => { + const attachedNodes = attachments.get(inputName) ?? []; + + if (input.config.singleton) { + if (attachedNodes.length > 1) { + const attachedNodeIds = attachedNodes.map(e => e.spec.id); + throw Error( + `expected ${ + input.config.optional ? 'at most' : 'exactly' + } one '${inputName}' input but received multiple: '${attachedNodeIds.join( + "', '", + )}'`, + ); + } else if (attachedNodes.length === 0) { + if (input.config.optional) { + return undefined; + } + throw Error(`input '${inputName}' is required but was not received`); + } + return resolveInputDataContainer( + input.extensionData, + attachedNodes[0], + inputName, + ); + } + + return attachedNodes.map(attachment => + resolveInputDataContainer(input.extensionData, attachment, inputName), + ); + }) as ResolvedExtensionInputs<{ + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }>; +} + /** @internal */ export function createAppNodeInstance(options: { node: AppNode; @@ -141,24 +236,71 @@ export function createAppNodeInstance(options: { try { const internalExtension = toInternalExtension(extension); - const namedOutputs = internalExtension.factory({ - node, - config: parsedConfig, - inputs: resolveInputs(id, internalExtension.inputs, attachments), - }); + if (process.env.NODE_ENV !== 'production') { + reportUndeclaredAttachments(id, internalExtension.inputs, attachments); + } - for (const [name, output] of Object.entries(namedOutputs)) { - const ref = internalExtension.output[name]; - if (!ref) { - throw new Error(`unknown output provided via '${name}'`); + if (internalExtension.version === 'v1') { + const namedOutputs = internalExtension.factory({ + node, + config: parsedConfig, + inputs: resolveV1Inputs(internalExtension.inputs, attachments), + }); + + for (const [name, output] of Object.entries(namedOutputs)) { + const ref = internalExtension.output[name]; + if (!ref) { + throw new Error(`unknown output provided via '${name}'`); + } + if (extensionData.has(ref.id)) { + throw new Error( + `duplicate extension data '${ref.id}' received via output '${name}'`, + ); + } + extensionData.set(ref.id, output); + extensionDataRefs.add(ref); } - if (extensionData.has(ref.id)) { + } else if (internalExtension.version === 'v2') { + const outputDataValues = internalExtension.factory({ + node, + config: parsedConfig, + inputs: resolveV2Inputs(internalExtension.inputs, attachments), + }); + + const outputDataMap = new Map(); + for (const value of outputDataValues) { + if (outputDataMap.has(value.id)) { + throw new Error(`duplicate extension data output '${value.id}'`); + } + outputDataMap.set(value.id, value.value); + } + + for (const ref of internalExtension.output) { + const value = outputDataMap.get(ref.id); + outputDataMap.delete(ref.id); + if (value === undefined) { + if (!ref.config.optional) { + throw new Error( + `missing required extension data output '${ref.id}'`, + ); + } + } else { + extensionData.set(ref.id, value); + extensionDataRefs.add(ref); + } + } + + if (outputDataMap.size > 0) { throw new Error( - `duplicate extension data '${ref.id}' received via output '${name}'`, + `unexpected output '${Array.from(outputDataMap.keys()).join( + "', '", + )}'`, ); } - extensionData.set(ref.id, output); - extensionDataRefs.add(ref); + } else { + throw new Error( + `unexpected extension version '${(internalExtension as any).version}'`, + ); } } catch (e) { throw new Error( diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 2c92f863e8..d37f0a3a56 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -147,20 +147,23 @@ export { AnyApiFactory }; export { AnyApiRef }; -// @public (undocumented) +// @public @deprecated (undocumented) export type AnyExtensionDataMap = { - [name in string]: ExtensionDataRef< - unknown, - string, - { - optional?: true; - } - >; + [name in string]: AnyExtensionDataRef; }; // @public (undocumented) +export type AnyExtensionDataRef = ExtensionDataRef< + unknown, + string, + { + optional?: true; + } +>; + +// @public @deprecated (undocumented) export type AnyExtensionInputMap = { - [inputName in string]: ExtensionInput< + [inputName in string]: LegacyExtensionInput< AnyExtensionDataMap, { optional: boolean; @@ -318,16 +321,18 @@ export { configApiRef }; // @public (undocumented) export interface ConfigurableExtensionDataRef< - TId extends string, TData, + TId extends string, TConfig extends { optional?: true; } = {}, > extends ExtensionDataRef { + // (undocumented) + (t: TData): ExtensionDataValue; // (undocumented) optional(): ConfigurableExtensionDataRef< - TId, TData, + TId, TData & { optional: true; } @@ -351,14 +356,14 @@ export type CoreErrorBoundaryFallbackProps = { // @public (undocumented) export const coreExtensionData: { reactElement: ConfigurableExtensionDataRef< - 'core.reactElement', JSX_2.Element, + 'core.reactElement', {} >; - routePath: ConfigurableExtensionDataRef<'core.routing.path', string, {}>; + routePath: ConfigurableExtensionDataRef; routeRef: ConfigurableExtensionDataRef< - 'core.routing.ref', RouteRef, + 'core.routing.ref', {} >; }; @@ -397,8 +402,8 @@ export function createApiExtension< export namespace createApiExtension { const // (undocumented) factoryDataRef: ConfigurableExtensionDataRef< - 'core.api.factory', AnyApiFactory, + 'core.api.factory', {} >; } @@ -455,10 +460,10 @@ export function createAppRootWrapperExtension< export namespace createAppRootWrapperExtension { const // (undocumented) componentDataRef: ConfigurableExtensionDataRef< - 'app.root.wrapper', React_2.ComponentType<{ children?: React_2.ReactNode; }>, + 'app.root.wrapper', {} >; } @@ -493,11 +498,11 @@ export function createComponentExtension< export namespace createComponentExtension { const // (undocumented) componentDataRef: ConfigurableExtensionDataRef< - 'core.component.component', { ref: ComponentRef; impl: ComponentType; }, + 'core.component.component', {} >; } @@ -508,6 +513,50 @@ export function createComponentRef(options: { }): ComponentRef; // @public (undocumented) +export function createExtension< + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { + optional: boolean; + singleton: boolean; + } + >; + }, + TConfig, + TConfigInput, + TConfigSchema extends { + [key: string]: (zImpl: typeof z) => z.ZodType; + }, + UFactoryOutput extends ExtensionDataValue, +>( + options: CreateExtensionOptions< + UOutput, + TInputs, + TConfig, + TConfigInput, + TConfigSchema, + UFactoryOutput + >, +): ExtensionDefinition< + TConfig & + (string extends keyof TConfigSchema + ? {} + : { + [key in keyof TConfigSchema]: z.infer>; + }), + TConfigInput & + (string extends keyof TConfigSchema + ? {} + : z.input< + z.ZodObject<{ + [key in keyof TConfigSchema]: ReturnType; + }> + >) +>; + +// @public @deprecated (undocumented) export function createExtension< TOutput extends AnyExtensionDataMap, TInputs extends AnyExtensionInputMap, @@ -517,7 +566,7 @@ export function createExtension< [key: string]: (zImpl: typeof z) => z.ZodType; }, >( - options: CreateExtensionOptions< + options: LegacyCreateExtensionOptions< TOutput, TInputs, TConfig, @@ -544,24 +593,38 @@ export function createExtension< // @public export function createExtensionBlueprint< TParams, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { + optional: boolean; + singleton: boolean; + } + >; + }, + UExtraOutput extends AnyExtensionDataRef, TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; }, - TDataRefs extends AnyExtensionDataMap = never, + UFactoryOutput extends ExtensionDataValue, + TDataRefs extends { + [name in string]: AnyExtensionDataRef; + } = never, >( options: CreateExtensionBlueprintOptions< TParams, + UOutput, TInputs, - TOutput, TConfigSchema, + UFactoryOutput, TDataRefs >, ): ExtensionBlueprint< TParams, + UOutput, TInputs, - TOutput, + UExtraOutput, string extends keyof TConfigSchema ? {} : { @@ -578,29 +641,38 @@ export function createExtensionBlueprint< >; // @public (undocumented) -export interface CreateExtensionBlueprintOptions< +export type CreateExtensionBlueprintOptions< TParams, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { + optional: boolean; + singleton: boolean; + } + >; + }, TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; }, - TDataRefs extends AnyExtensionDataMap, -> { - // (undocumented) + UFactoryOutput extends ExtensionDataValue, + TDataRefs extends { + [name in string]: AnyExtensionDataRef; + }, +> = { + kind: string; + namespace?: string; attachTo: { id: string; input: string; }; - // (undocumented) + disabled?: boolean; + inputs?: TInputs; + output: Array; config?: { schema: TConfigSchema; }; - // (undocumented) - dataRefs?: TDataRefs; - // (undocumented) - disabled?: boolean; - // (undocumented) factory( params: TParams, context: { @@ -610,41 +682,34 @@ export interface CreateExtensionBlueprintOptions< }; inputs: Expand>; }, - ): Expand>; - // (undocumented) - inputs?: TInputs; - // (undocumented) - kind: string; - // (undocumented) - namespace?: string; - // (undocumented) - output: TOutput; -} + ): Iterable; + dataRefs?: TDataRefs; +} & VerifyExtensionFactoryOutput; // @public @deprecated (undocumented) export function createExtensionDataRef( id: string, -): ConfigurableExtensionDataRef; +): ConfigurableExtensionDataRef; // @public (undocumented) export function createExtensionDataRef(): { with(options: { id: TId; - }): ConfigurableExtensionDataRef; + }): ConfigurableExtensionDataRef; }; -// @public (undocumented) +// @public @deprecated (undocumented) export function createExtensionInput< - TExtensionData extends AnyExtensionDataMap, + TExtensionDataMap extends AnyExtensionDataMap, TConfig extends { singleton?: boolean; optional?: boolean; }, >( - extensionData: TExtensionData, + extensionData: TExtensionDataMap, config?: TConfig, -): ExtensionInput< - TExtensionData, +): LegacyExtensionInput< + TExtensionDataMap, { singleton: TConfig['singleton'] extends true ? true : false; optional: TConfig['optional'] extends true ? true : false; @@ -652,29 +717,62 @@ export function createExtensionInput< >; // @public (undocumented) -export interface CreateExtensionOptions< - TOutput extends AnyExtensionDataMap, - TInputs extends AnyExtensionInputMap, +export function createExtensionInput< + UExtensionData extends ExtensionDataRef< + unknown, + string, + { + optional?: true; + } + >, + TConfig extends { + singleton?: boolean; + optional?: boolean; + }, +>( + extensionData: Array, + config?: TConfig, +): ExtensionInput< + UExtensionData, + { + singleton: TConfig['singleton'] extends true ? true : false; + optional: TConfig['optional'] extends true ? true : false; + } +>; + +// @public (undocumented) +export type CreateExtensionOptions< + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { + optional: boolean; + singleton: boolean; + } + >; + }, TConfig, TConfigInput, TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType; }, -> { - // (undocumented) + UFactoryOutput extends ExtensionDataValue, +> = { + kind?: string; + namespace?: string; + name?: string; attachTo: { id: string; input: string; }; - // (undocumented) + disabled?: boolean; + inputs?: TInputs; + output: Array; + configSchema?: PortableSchema; config?: { schema: TConfigSchema; }; - // @deprecated (undocumented) - configSchema?: PortableSchema; - // (undocumented) - disabled?: boolean; - // (undocumented) factory(context: { node: AppNode; config: TConfig & @@ -686,18 +784,8 @@ export interface CreateExtensionOptions< >; }); inputs: Expand>; - }): Expand>; - // (undocumented) - inputs?: TInputs; - // (undocumented) - kind?: string; - // (undocumented) - name?: string; - // (undocumented) - namespace?: string; - // (undocumented) - output: TOutput; -} + }): Iterable; +} & VerifyExtensionFactoryOutput; // @public (undocumented) export function createExtensionOverrides( @@ -750,12 +838,12 @@ export function createNavItemExtension(options: { export namespace createNavItemExtension { const // (undocumented) targetDataRef: ConfigurableExtensionDataRef< - 'core.nav-item.target', { title: string; icon: IconComponent_2; routeRef: RouteRef; }, + 'core.nav-item.target', {} >; } @@ -772,11 +860,11 @@ export function createNavLogoExtension(options: { export namespace createNavLogoExtension { const // (undocumented) logoElementsDataRef: ConfigurableExtensionDataRef< - 'core.nav-logo.logo-elements', { logoIcon?: JSX.Element | undefined; logoFull?: JSX.Element | undefined; }, + 'core.nav-logo.logo-elements', {} >; } @@ -866,10 +954,10 @@ export function createRouterExtension< export namespace createRouterExtension { const // (undocumented) componentDataRef: ConfigurableExtensionDataRef< - 'app.router.wrapper', React_2.ComponentType<{ children?: React_2.ReactNode; }>, + 'app.router.wrapper', {} >; } @@ -903,8 +991,8 @@ export function createSignInPageExtension< export namespace createSignInPageExtension { const // (undocumented) componentDataRef: ConfigurableExtensionDataRef< - 'core.sign-in-page.component', React_2.ComponentType, + 'core.sign-in-page.component', {} >; } @@ -927,8 +1015,8 @@ export function createThemeExtension( export namespace createThemeExtension { const // (undocumented) themeDataRef: ConfigurableExtensionDataRef< - 'core.theme.theme', AppTheme, + 'core.theme.theme', {} >; } @@ -943,7 +1031,6 @@ export function createTranslationExtension(options: { export namespace createTranslationExtension { const // (undocumented) translationDataRef: ConfigurableExtensionDataRef< - 'core.translation.translation', | TranslationResource | TranslationMessages< string, @@ -952,6 +1039,7 @@ export namespace createTranslationExtension { }, boolean >, + 'core.translation.translation', {} >; } @@ -994,15 +1082,26 @@ export interface Extension { // @public (undocumented) export interface ExtensionBlueprint< TParams, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { + optional: boolean; + singleton: boolean; + } + >; + }, + UExtraOutput extends AnyExtensionDataRef, TConfig extends { [key in string]: unknown; }, TConfigInput extends { [key in string]: unknown; }, - TDataRefs extends AnyExtensionDataMap, + TDataRefs extends { + [name in string]: AnyExtensionDataRef; + }, > { // (undocumented) dataRefs: TDataRefs; @@ -1010,6 +1109,7 @@ export interface ExtensionBlueprint< TExtensionConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; }, + UFactoryOutput extends ExtensionDataValue, >( args: { namespace?: string; @@ -1020,7 +1120,7 @@ export interface ExtensionBlueprint< }; disabled?: boolean; inputs?: TInputs; - output?: TOutput; + output?: Array; config?: { schema: TExtensionConfigSchema & { [KName in keyof TConfig]?: `Error: Config key '${KName & @@ -1028,7 +1128,7 @@ export interface ExtensionBlueprint< }; }; } & ( - | { + | ({ factory( originalFactory: ( params: TParams, @@ -1036,7 +1136,7 @@ export interface ExtensionBlueprint< config?: TConfig; inputs?: Expand>; }, - ) => Expand>, + ) => Iterable>, context: { node: AppNode; config: TConfig & { @@ -1046,8 +1146,11 @@ export interface ExtensionBlueprint< }; inputs: Expand>; }, - ): Expand>; - } + ): Iterable; + } & VerifyExtensionFactoryOutput< + UOutput & UExtraOutput, + UFactoryOutput + >) | { params: TParams; } @@ -1083,6 +1186,18 @@ export interface ExtensionBoundaryProps { routable?: boolean; } +// @public (undocumented) +export type ExtensionDataContainer = + { + get( + ref: ExtensionDataRef, + ): UExtensionData extends ExtensionDataRef + ? IConfig['optional'] extends true + ? IData | undefined + : IData + : never; + }; + // @public (undocumented) export type ExtensionDataRef< TData, @@ -1091,13 +1206,26 @@ export type ExtensionDataRef< optional?: true; } = {}, > = { - id: TId; - T: TData; - config: TConfig; - $$type: '@backstage/ExtensionDataRef'; + readonly $$type: '@backstage/ExtensionDataRef'; + readonly id: TId; + readonly T: TData; + readonly config: TConfig; }; -// @public +// @public (undocumented) +export type ExtensionDataRefToValue = + TDataRef extends ExtensionDataRef + ? ExtensionDataValue + : never; + +// @public (undocumented) +export type ExtensionDataValue = { + readonly $$type: '@backstage/ExtensionDataValue'; + readonly id: TId; + readonly value: TData; +}; + +// @public @deprecated export type ExtensionDataValues = { [DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends { optional: true; @@ -1135,7 +1263,13 @@ export interface ExtensionDefinition { // @public (undocumented) export interface ExtensionInput< - TExtensionData extends AnyExtensionDataMap, + TExtensionData extends ExtensionDataRef< + unknown, + string, + { + optional?: true; + } + >, TConfig extends { singleton: boolean; optional: boolean; @@ -1146,7 +1280,7 @@ export interface ExtensionInput< // (undocumented) config: TConfig; // (undocumented) - extensionData: TExtensionData; + extensionData: Array; } // @public (undocumented) @@ -1211,16 +1345,23 @@ export const IconBundleBlueprint: ExtensionBlueprint< [x: string]: IconComponent; }; }, - AnyExtensionInputMap, + ConfigurableExtensionDataRef< + { + [x: string]: IconComponent; + }, + 'core.icons', + {} + >, { - icons: ConfigurableExtensionDataRef< - 'core.icons', + [x: string]: ExtensionInput< + AnyExtensionDataRef, { - [x: string]: IconComponent; - }, - {} + optional: boolean; + singleton: boolean; + } >; }, + AnyExtensionDataRef, { icons: string; test: string; @@ -1231,10 +1372,10 @@ export const IconBundleBlueprint: ExtensionBlueprint< }, { icons: ConfigurableExtensionDataRef< - 'core.icons', { [x: string]: IconComponent; }, + 'core.icons', {} >; } @@ -1265,6 +1406,70 @@ export { IdentityApi }; export { identityApiRef }; +// @public @deprecated (undocumented) +export interface LegacyCreateExtensionOptions< + TOutput extends AnyExtensionDataMap, + TInputs extends AnyExtensionInputMap, + TConfig, + TConfigInput, + TConfigSchema extends { + [key: string]: (zImpl: typeof z) => z.ZodType; + }, +> { + // (undocumented) + attachTo: { + id: string; + input: string; + }; + // (undocumented) + config?: { + schema: TConfigSchema; + }; + // @deprecated (undocumented) + configSchema?: PortableSchema; + // (undocumented) + disabled?: boolean; + // (undocumented) + factory(context: { + node: AppNode; + config: TConfig & + (string extends keyof TConfigSchema + ? {} + : { + [key in keyof TConfigSchema]: z.infer< + ReturnType + >; + }); + inputs: Expand>; + }): Expand>; + // (undocumented) + inputs?: TInputs; + // (undocumented) + kind?: string; + // (undocumented) + name?: string; + // (undocumented) + namespace?: string; + // (undocumented) + output: TOutput; +} + +// @public @deprecated (undocumented) +export interface LegacyExtensionInput< + TExtensionDataMap extends AnyExtensionDataMap, + TConfig extends { + singleton: boolean; + optional: boolean; + }, +> { + // (undocumented) + $$type: '@backstage/ExtensionInput'; + // (undocumented) + config: TConfig; + // (undocumented) + extensionData: TExtensionDataMap; +} + export { microsoftAuthApiRef }; export { OAuthApi }; @@ -1315,25 +1520,30 @@ export { ProfileInfo }; export { ProfileInfoApi }; // @public -export type ResolvedExtensionInput = - { - node: AppNode; - output: ExtensionDataValues; - }; +export type ResolvedExtensionInput< + TExtensionInput extends ExtensionInput, +> = TExtensionInput['extensionData'] extends Array + ? { + node: AppNode; + } & ExtensionDataContainer + : TExtensionInput['extensionData'] extends AnyExtensionDataMap + ? { + node: AppNode; + output: ExtensionDataValues; + } + : never; // @public export type ResolvedExtensionInputs< TInputs extends { - [name in string]: ExtensionInput; + [name in string]: ExtensionInput | LegacyExtensionInput; }, > = { [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] - ? Array>> + ? Array>> : false extends TInputs[InputName]['config']['optional'] - ? Expand> - : Expand< - ResolvedExtensionInput | undefined - >; + ? Expand> + : Expand | undefined>; }; // @public diff --git a/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts b/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts index 438da09b4a..deeabdb44c 100644 --- a/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts +++ b/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts @@ -25,16 +25,16 @@ export const IconBundleBlueprint = createExtensionBlueprint({ kind: 'icon-bundle', namespace: 'app', attachTo: { id: 'app', input: 'icons' }, - output: { - icons: iconsDataRef, - }, + output: [iconsDataRef], config: { schema: { icons: z => z.string().default('blob'), test: z => z.string(), }, }, - factory: (params: { icons: { [key in string]: IconComponent } }) => params, + factory: (params: { icons: { [key in string]: IconComponent } }) => [ + iconsDataRef(params.icons), + ], dataRefs: { icons: iconsDataRef, }, diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index 5b737746c2..327639af37 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -18,7 +18,8 @@ import { createExtension } from './createExtension'; import { createExtensionDataRef } from './createExtensionDataRef'; import { createExtensionInput } from './createExtensionInput'; -const stringData = createExtensionDataRef().with({ id: 'string' }); +const stringDataRef = createExtensionDataRef().with({ id: 'string' }); +const numberDataRef = createExtensionDataRef().with({ id: 'number' }); function unused(..._any: any[]) {} @@ -28,7 +29,7 @@ describe('createExtension', () => { namespace: 'test', attachTo: { id: 'root', input: 'default' }, output: { - foo: stringData, + foo: stringDataRef, }, }; const extension = createExtension({ @@ -39,14 +40,14 @@ describe('createExtension', () => { }; }, }); - expect(extension.namespace).toBe('test'); + expect(extension).toMatchObject({ version: 'v1', namespace: 'test' }); // When declared as an error function without a block the TypeScript errors - // are a more specific and will point at the property that is problematic. + // are a more specific and will often point at the property that is problematic. + // @ts-expect-error createExtension({ ...baseConfig, factory: () => ({ - // @ts-expect-error foo: 3, }), }); @@ -166,8 +167,8 @@ describe('createExtension', () => { namespace: 'test', attachTo: { id: 'root', input: 'default' }, output: { - foo: stringData, - bar: stringData.optional(), + foo: stringDataRef, + bar: stringDataRef.optional(), }, }; const extension = createExtension({ @@ -176,7 +177,7 @@ describe('createExtension', () => { foo: 'bar', }), }); - expect(extension.namespace).toBe('test'); + expect(extension).toMatchObject({ version: 'v1', namespace: 'test' }); createExtension({ ...baseConfig, @@ -185,18 +186,18 @@ describe('createExtension', () => { bar: 'baz', }), }); + // @ts-expect-error createExtension({ ...baseConfig, factory: () => ({ - // @ts-expect-error foo: 3, }), }); + // @ts-expect-error createExtension({ ...baseConfig, factory: () => ({ foo: 'bar', - // @ts-expect-error bar: 3, }), }); @@ -237,18 +238,18 @@ describe('createExtension', () => { attachTo: { id: 'root', input: 'default' }, inputs: { mixed: createExtensionInput({ - required: stringData, - optional: stringData.optional(), + required: stringDataRef, + optional: stringDataRef.optional(), }), onlyRequired: createExtensionInput({ - required: stringData, + required: stringDataRef, }), onlyOptional: createExtensionInput({ - optional: stringData.optional(), + optional: stringDataRef.optional(), }), }, output: { - foo: stringData, + foo: stringDataRef, }, factory({ inputs }) { const a1: string = inputs.mixed?.[0].output.required; @@ -286,7 +287,7 @@ describe('createExtension', () => { }; }, }); - expect(extension.namespace).toBe('test'); + expect(extension).toMatchObject({ version: 'v1', namespace: 'test' }); expect(String(extension)).toBe( 'ExtensionDefinition{namespace=test,attachTo=root@default}', ); @@ -304,7 +305,7 @@ describe('createExtension', () => { }, }, output: { - foo: stringData, + foo: stringDataRef, }, factory({ config }) { const a1: string = config.foo; @@ -324,7 +325,7 @@ describe('createExtension', () => { }; }, }); - expect(extension.namespace).toBe('test'); + expect(extension).toMatchObject({ version: 'v1', namespace: 'test' }); expect(String(extension)).toBe( 'ExtensionDefinition{namespace=test,attachTo=root@default}', ); @@ -355,4 +356,110 @@ describe('createExtension', () => { return extension.configSchema?.parse({}); }).toThrow("Missing required value at 'foo'"); }); + + it('should support new form of outputs', () => { + expect( + // @ts-expect-error + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef], + factory() { + return []; // Missing all outputs + }, + }), + ).toMatchObject({ version: 'v2' }); + + expect( + // @ts-expect-error + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef], + factory() { + return [stringDataRef('hello')]; // Missing number output + }, + }), + ).toMatchObject({ version: 'v2' }); + + // Duplicate output, we won't attempt to handle this a compile time and instead error out at runtime + expect( + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef], + factory() { + return [stringDataRef('hello'), stringDataRef('hello')]; + }, + }), + ).toMatchObject({ version: 'v2' }); + + expect( + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef], + factory() { + return [stringDataRef('hello'), numberDataRef(4)]; + }, + }), + ).toMatchObject({ version: 'v2' }); + + expect( + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef.optional()], + factory() { + return [stringDataRef('hello'), numberDataRef(4)]; + }, + }), + ).toMatchObject({ version: 'v2' }); + + expect( + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef.optional()], + factory() { + return [stringDataRef('hello')]; // Missing number output, but it's optional so that's allowed + }, + }), + ).toMatchObject({ version: 'v2' }); + }); + + it('should support new form of inputs', () => { + expect( + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + inputs: { + header: createExtensionInput([stringDataRef.optional()], { + optional: true, + singleton: true, + }), + content: createExtensionInput([stringDataRef, numberDataRef], { + optional: false, + singleton: true, + }), + }, + output: [stringDataRef], + factory({ inputs }) { + const headerStr = inputs.header?.get(stringDataRef); + const contentStr = inputs.content.get(stringDataRef); + const contentNum = inputs.content.get(numberDataRef); + + // @ts-expect-error + inputs.header?.get(numberDataRef); + + // @ts-expect-error + const x1: string = headerStr; // string | undefined + + unused(x1); + + return [stringDataRef(contentStr.repeat(contentNum))]; + }, + }), + ).toMatchObject({ version: 'v2' }); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index cc2bec91c9..4d31982ed3 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -17,17 +17,28 @@ import { AppNode } from '../apis'; import { PortableSchema, createSchemaFromZod } from '../schema'; import { Expand } from '../types'; -import { ExtensionDataRef } from './createExtensionDataRef'; -import { ExtensionInput } from './createExtensionInput'; +import { + AnyExtensionDataRef, + ExtensionDataRef, + ExtensionDataValue, +} from './createExtensionDataRef'; +import { ExtensionInput, LegacyExtensionInput } from './createExtensionInput'; import { z } from 'zod'; -/** @public */ + +/** + * @public + * @deprecated Extension data maps will be removed. + */ export type AnyExtensionDataMap = { - [name in string]: ExtensionDataRef; + [name in string]: AnyExtensionDataRef; }; -/** @public */ +/** + * @public + * @deprecated This type will be removed. + */ export type AnyExtensionInputMap = { - [inputName in string]: ExtensionInput< + [inputName in string]: LegacyExtensionInput< AnyExtensionDataMap, { optional: boolean; singleton: boolean } >; @@ -36,6 +47,7 @@ export type AnyExtensionInputMap = { /** * Converts an extension data map into the matching concrete data values type. * @public + * @deprecated Extension data maps will be removed. */ export type ExtensionDataValues = { [DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends { @@ -51,34 +63,56 @@ export type ExtensionDataValues = { : never]?: TExtensionData[DataName]['T']; }; +/** @public */ +export type ExtensionDataContainer = + { + get( + ref: ExtensionDataRef, + ): UExtensionData extends ExtensionDataRef + ? IConfig['optional'] extends true + ? IData | undefined + : IData + : never; + }; + /** * Convert a single extension input into a matching resolved input. * @public */ -export type ResolvedExtensionInput = - { - node: AppNode; - output: ExtensionDataValues; - }; +export type ResolvedExtensionInput< + TExtensionInput extends ExtensionInput, +> = TExtensionInput['extensionData'] extends Array + ? { + node: AppNode; + } & ExtensionDataContainer + : TExtensionInput['extensionData'] extends AnyExtensionDataMap + ? { + node: AppNode; + output: ExtensionDataValues; + } + : never; /** * Converts an extension input map into a matching collection of resolved inputs. * @public */ export type ResolvedExtensionInputs< - TInputs extends { [name in string]: ExtensionInput }, + TInputs extends { + [name in string]: ExtensionInput | LegacyExtensionInput; + }, > = { [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] - ? Array>> + ? Array>> : false extends TInputs[InputName]['config']['optional'] - ? Expand> - : Expand< - ResolvedExtensionInput | undefined - >; + ? Expand> + : Expand | undefined>; }; -/** @public */ -export interface CreateExtensionOptions< +/** + * @public + * @deprecated This way of structuring the options is deprecated, this type will be removed in the future + */ +export interface LegacyCreateExtensionOptions< TOutput extends AnyExtensionDataMap, TInputs extends AnyExtensionInputMap, TConfig, @@ -111,6 +145,67 @@ export interface CreateExtensionOptions< }): Expand>; } +/** @ignore */ +export type VerifyExtensionFactoryOutput< + UDeclaredOutput extends AnyExtensionDataRef, + UFactoryOutput extends ExtensionDataValue, +> = ( + UDeclaredOutput extends any + ? UDeclaredOutput['config']['optional'] extends true + ? never + : UDeclaredOutput['id'] + : never +) extends infer IRequiredOutputIds + ? [IRequiredOutputIds] extends [UFactoryOutput['id']] + ? {} + : { + 'Error: The extension factory is missing the following outputs': Exclude< + IRequiredOutputIds, + UFactoryOutput['id'] + >; + } + : never; + +/** @public */ +export type CreateExtensionOptions< + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, + TConfig, + TConfigInput, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, + UFactoryOutput extends ExtensionDataValue, +> = { + kind?: string; + namespace?: string; + name?: string; + attachTo: { id: string; input: string }; + disabled?: boolean; + inputs?: TInputs; + output: Array; + /** @deprecated - use `config.schema` instead */ + configSchema?: PortableSchema; + config?: { + schema: TConfigSchema; + }; + factory(context: { + node: AppNode; + config: TConfig & + (string extends keyof TConfigSchema + ? {} + : { + [key in keyof TConfigSchema]: z.infer< + ReturnType + >; + }); + inputs: Expand>; + }): Iterable; +} & VerifyExtensionFactoryOutput; + /** @public */ export interface ExtensionDefinition { $$type: '@backstage/ExtensionDefinition'; @@ -123,17 +218,40 @@ export interface ExtensionDefinition { } /** @internal */ -export interface InternalExtensionDefinition - extends ExtensionDefinition { - readonly version: 'v1'; - readonly inputs: AnyExtensionInputMap; - readonly output: AnyExtensionDataMap; - factory(context: { - node: AppNode; - config: TConfig; - inputs: ResolvedExtensionInputs; - }): ExtensionDataValues; -} +export type InternalExtensionDefinition = + ExtensionDefinition & + ( + | { + readonly version: 'v1'; + readonly inputs: AnyExtensionInputMap; + readonly output: AnyExtensionDataMap; + factory(context: { + node: AppNode; + config: TConfig; + inputs: ResolvedExtensionInputs; + }): ExtensionDataValues; + } + | { + readonly version: 'v2'; + readonly inputs: { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }; + readonly output: Array; + factory(context: { + node: AppNode; + config: TConfig; + inputs: ResolvedExtensionInputs<{ + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }>; + }): Iterable>; + } + ); /** @internal */ export function toInternalExtensionDefinition( @@ -148,15 +266,57 @@ export function toInternalExtensionDefinition( `Invalid extension definition instance, bad type '${internal.$$type}'`, ); } - if (internal.version !== 'v1') { + const version = internal.version; + if (version !== 'v1' && version !== 'v2') { throw new Error( - `Invalid extension definition instance, bad version '${internal.version}'`, + `Invalid extension definition instance, bad version '${version}'`, ); } return internal; } /** @public */ +export function createExtension< + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, + TConfig, + TConfigInput, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, + UFactoryOutput extends ExtensionDataValue, +>( + options: CreateExtensionOptions< + UOutput, + TInputs, + TConfig, + TConfigInput, + TConfigSchema, + UFactoryOutput + >, +): ExtensionDefinition< + TConfig & + (string extends keyof TConfigSchema + ? {} + : { + [key in keyof TConfigSchema]: z.infer>; + }), + TConfigInput & + (string extends keyof TConfigSchema + ? {} + : z.input< + z.ZodObject<{ + [key in keyof TConfigSchema]: ReturnType; + }> + >) +>; +/** + * @public + * @deprecated - use the array format of `output` instead, see TODO-doc-link + */ export function createExtension< TOutput extends AnyExtensionDataMap, TInputs extends AnyExtensionInputMap, @@ -164,7 +324,7 @@ export function createExtension< TConfigInput, TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, >( - options: CreateExtensionOptions< + options: LegacyCreateExtensionOptions< TOutput, TInputs, TConfig, @@ -186,6 +346,52 @@ export function createExtension< [key in keyof TConfigSchema]: ReturnType; }> >) +>; +export function createExtension< + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, + TLegacyInputs extends AnyExtensionInputMap, + TConfig, + TConfigInput, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, + UFactoryOutput extends ExtensionDataValue, +>( + options: + | CreateExtensionOptions< + UOutput, + TInputs, + TConfig, + TConfigInput, + TConfigSchema, + UFactoryOutput + > + | LegacyCreateExtensionOptions< + AnyExtensionDataMap, + TLegacyInputs, + TConfig, + TConfigInput, + TConfigSchema + >, +): ExtensionDefinition< + TConfig & + (string extends keyof TConfigSchema + ? {} + : { + [key in keyof TConfigSchema]: z.infer>; + }), + TConfigInput & + (string extends keyof TConfigSchema + ? {} + : z.input< + z.ZodObject<{ + [key in keyof TConfigSchema]: ReturnType; + }> + >) > { const newConfigSchema = options.config?.schema; if (newConfigSchema && options.configSchema) { @@ -203,7 +409,7 @@ export function createExtension< return { $$type: '@backstage/ExtensionDefinition', - version: 'v1', + version: Symbol.iterator in options.output ? 'v2' : 'v1', kind: options.kind, namespace: options.namespace, name: options.name, @@ -212,16 +418,7 @@ export function createExtension< inputs: options.inputs ?? {}, output: options.output, configSchema, - factory({ inputs, config, ...rest }) { - // TODO: Simplify this, but TS wouldn't infer the input type for some reason - return options.factory({ - inputs: inputs as Expand>, - config: config as TConfig & { - [key in keyof TConfigSchema]: z.infer>; - }, - ...rest, - }); - }, + factory: options.factory, toString() { const parts: string[] = []; if (options.kind) { diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index cb8855d892..8b364f33c1 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -27,13 +27,9 @@ describe('createExtensionBlueprint', () => { const TestExtensionBlueprint = createExtensionBlueprint({ kind: 'test-extension', attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, + output: [coreExtensionData.reactElement], factory(params: { text: string }) { - return { - element:

{params.text}

, - }; + return [coreExtensionData.reactElement(

{params.text}

)]; }, }); @@ -56,18 +52,10 @@ describe('createExtensionBlueprint', () => { kind: 'test-extension', name: 'my-extension', namespace: undefined, - output: { - element: { - $$type: '@backstage/ExtensionDataRef', - config: {}, - id: 'core.reactElement', - optional: expect.any(Function), - toString: expect.any(Function), - }, - }, + output: [coreExtensionData.reactElement], factory: expect.any(Function), toString: expect.any(Function), - version: 'v1', + version: 'v2', }); const { container } = createExtensionTester(extension).render(); @@ -78,13 +66,9 @@ describe('createExtensionBlueprint', () => { const TestExtensionBlueprint = createExtensionBlueprint({ kind: 'test-extension', attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, + output: [coreExtensionData.reactElement], factory(params: { text: string }) { - return { - element:

{params.text}

, - }; + return [coreExtensionData.reactElement(

{params.text}

)]; }, }); @@ -109,16 +93,12 @@ describe('createExtensionBlueprint', () => { const TestExtensionBlueprint = createExtensionBlueprint({ kind: 'test-extension', attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, + output: [coreExtensionData.reactElement], dataRefs: { data: dataRef, }, factory(params: { text: string }) { - return { - element:

{params.text}

, - }; + return [coreExtensionData.reactElement(

{params.text}

)]; }, }); @@ -131,9 +111,7 @@ describe('createExtensionBlueprint', () => { const TestExtensionBlueprint = createExtensionBlueprint({ kind: 'test-extension', attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, + output: [coreExtensionData.reactElement], config: { schema: { text: z => z.string(), @@ -148,9 +126,7 @@ describe('createExtensionBlueprint', () => { expect(config.text).toBe('Hello, world!'); - return { - element:

{config.text}

, - }; + return [coreExtensionData.reactElement(

{config.text}

)]; }, }); @@ -194,18 +170,14 @@ describe('createExtensionBlueprint', () => { const TestExtensionBlueprint = createExtensionBlueprint({ kind: 'test-extension', attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, + output: [coreExtensionData.reactElement], config: { schema: { text: z => z.string(), }, }, factory(params: { text: string }) { - return { - element:
{params.text}
, - }; + return [coreExtensionData.reactElement(
{params.text}
)]; }, }); @@ -230,16 +202,12 @@ describe('createExtensionBlueprint', () => { const TestExtensionBlueprint = createExtensionBlueprint({ kind: 'test-extension', attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, + output: [coreExtensionData.reactElement], factory(_, { config }) { // @ts-expect-error const b = config.something; - return { - element:
, - }; + return [coreExtensionData.reactElement(
)]; }, }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 7393ca233e..b579cc207a 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -17,31 +17,42 @@ import { AppNode } from '../apis'; import { Expand } from '../types'; import { - AnyExtensionDataMap, - AnyExtensionInputMap, - ExtensionDataValues, + CreateExtensionOptions, ExtensionDefinition, ResolvedExtensionInputs, + VerifyExtensionFactoryOutput, createExtension, } from './createExtension'; import { z } from 'zod'; +import { ExtensionInput } from './createExtensionInput'; +import { + AnyExtensionDataRef, + ExtensionDataRefToValue, + ExtensionDataValue, +} from './createExtensionDataRef'; /** * @public */ -export interface CreateExtensionBlueprintOptions< +export type CreateExtensionBlueprintOptions< TParams, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, - TDataRefs extends AnyExtensionDataMap, -> { + UFactoryOutput extends ExtensionDataValue, + TDataRefs extends { [name in string]: AnyExtensionDataRef }, +> = { kind: string; namespace?: string; attachTo: { id: string; input: string }; disabled?: boolean; inputs?: TInputs; - output: TOutput; + output: Array; config?: { schema: TConfigSchema; }; @@ -54,21 +65,27 @@ export interface CreateExtensionBlueprintOptions< }; inputs: Expand>; }, - ): Expand>; + ): Iterable; dataRefs?: TDataRefs; -} +} & VerifyExtensionFactoryOutput; /** * @public */ export interface ExtensionBlueprint< TParams, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, + UExtraOutput extends AnyExtensionDataRef, TConfig extends { [key in string]: unknown }, TConfigInput extends { [key in string]: unknown }, - TDataRefs extends AnyExtensionDataMap, + TDataRefs extends { [name in string]: AnyExtensionDataRef }, > { dataRefs: TDataRefs; @@ -82,6 +99,7 @@ export interface ExtensionBlueprint< TExtensionConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; }, + UFactoryOutput extends ExtensionDataValue, >( args: { namespace?: string; @@ -89,7 +107,7 @@ export interface ExtensionBlueprint< attachTo?: { id: string; input: string }; disabled?: boolean; inputs?: TInputs; - output?: TOutput; + output?: Array; config?: { schema: TExtensionConfigSchema & { [KName in keyof TConfig]?: `Error: Config key '${KName & @@ -97,7 +115,7 @@ export interface ExtensionBlueprint< }; }; } & ( - | { + | ({ factory( originalFactory: ( params: TParams, @@ -105,7 +123,7 @@ export interface ExtensionBlueprint< config?: TConfig; inputs?: Expand>; }, - ) => Expand>, + ) => Iterable>, context: { node: AppNode; config: TConfig & { @@ -115,8 +133,11 @@ export interface ExtensionBlueprint< }; inputs: Expand>; }, - ): Expand>; - } + ): Iterable; + } & VerifyExtensionFactoryOutput< + UOutput & UExtraOutput, + UFactoryOutput + >) | { params: TParams; } @@ -143,17 +164,24 @@ export interface ExtensionBlueprint< */ class ExtensionBlueprintImpl< TParams, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, + UExtraOutput extends AnyExtensionDataRef, TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, - TDataRefs extends AnyExtensionDataMap, + TDataRefs extends { [name in string]: AnyExtensionDataRef }, > { constructor( private readonly options: CreateExtensionBlueprintOptions< TParams, + UOutput, TInputs, - TOutput, TConfigSchema, + any, TDataRefs >, ) { @@ -166,13 +194,14 @@ class ExtensionBlueprintImpl< TExtensionConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; }, + UFactoryOutput extends ExtensionDataValue, >(args: { namespace?: string; name?: string; attachTo?: { id: string; input: string }; disabled?: boolean; inputs?: TInputs; - output?: TOutput; + output?: Array; params?: TParams; config?: { schema: TExtensionConfigSchema; @@ -188,7 +217,7 @@ class ExtensionBlueprintImpl< }; inputs?: Expand>; }, - ) => Expand>, + ) => Iterable>, context: { node: AppNode; config: { @@ -200,7 +229,7 @@ class ExtensionBlueprintImpl< }; inputs: Expand>; }, - ): Expand>; + ): Iterable; }): ExtensionDefinition< { [key in keyof TExtensionConfigSchema]: z.infer< @@ -232,7 +261,7 @@ class ExtensionBlueprintImpl< attachTo: args.attachTo ?? this.options.attachTo, disabled: args.disabled ?? this.options.disabled, inputs: args.inputs ?? this.options.inputs, - output: args.output ?? this.options.output, + output: [...(args.output ?? []), ...this.options.output], config: Object.keys(schema).length === 0 ? undefined : { schema }, factory: ({ node, config, inputs }) => { if (args.factory) { @@ -247,12 +276,13 @@ class ExtensionBlueprintImpl< }; inputs?: Expand>; }, - ) => - this.options.factory(innerParams, { + ): Iterable> => { + return this.options.factory(innerParams, { node, config: innerContext?.config ?? config, inputs: innerContext?.inputs ?? inputs, - }), + }); + }, { node, config, @@ -268,7 +298,30 @@ class ExtensionBlueprintImpl< } throw new Error('Either params or factory must be provided'); }, - }); + } as CreateExtensionOptions< + UOutput, + TInputs, + { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + } & { + [key in keyof TConfigSchema]: z.infer>; + }, + z.input< + z.ZodObject< + { + [key in keyof TExtensionConfigSchema]: ReturnType< + TExtensionConfigSchema[key] + >; + } & { + [key in keyof TConfigSchema]: ReturnType; + } + > + >, + TConfigSchema, + UFactoryOutput + >); } } @@ -280,22 +333,31 @@ class ExtensionBlueprintImpl< */ export function createExtensionBlueprint< TParams, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, + UExtraOutput extends AnyExtensionDataRef, TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, - TDataRefs extends AnyExtensionDataMap = never, + UFactoryOutput extends ExtensionDataValue, + TDataRefs extends { [name in string]: AnyExtensionDataRef } = never, >( options: CreateExtensionBlueprintOptions< TParams, + UOutput, TInputs, - TOutput, TConfigSchema, + UFactoryOutput, TDataRefs >, ): ExtensionBlueprint< TParams, + UOutput, TInputs, - TOutput, + UExtraOutput, string extends keyof TConfigSchema ? {} : { [key in keyof TConfigSchema]: z.infer> }, @@ -310,8 +372,9 @@ export function createExtensionBlueprint< > { return new ExtensionBlueprintImpl(options) as ExtensionBlueprint< TParams, + UOutput, TInputs, - TOutput, + UExtraOutput, string extends keyof TConfigSchema ? {} : { diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.test.ts b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.test.ts index f979a7def0..4b652298f1 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.test.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { createExtensionDataRef } from './createExtensionDataRef'; +import { + ExtensionDataValue, + createExtensionDataRef, +} from './createExtensionDataRef'; describe('createExtensionDataRef', () => { it('can be created and read', () => { @@ -34,4 +37,18 @@ describe('createExtensionDataRef', () => { expect(refOptional.id).toBe('foo'); expect(String(refOptional)).toBe('ExtensionDataRef{id=foo,optional=true}'); }); + + it('can be used to encapsulate a value', () => { + const ref = createExtensionDataRef().with({ id: 'foo' }); + const val: ExtensionDataValue = ref('hello'); + expect(val).toEqual({ + $$type: '@backstage/ExtensionDataValue', + id: 'foo', + value: 'hello', + }); + // @ts-expect-error + ref(3); + // @ts-expect-error + ref(); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts index a1c7b462f2..c77aa8c394 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts @@ -14,29 +14,50 @@ * limitations under the License. */ +/** @public */ +export type ExtensionDataValue = { + readonly $$type: '@backstage/ExtensionDataValue'; + readonly id: TId; + readonly value: TData; +}; + /** @public */ export type ExtensionDataRef< TData, TId extends string = string, TConfig extends { optional?: true } = {}, > = { - id: TId; - T: TData; - config: TConfig; - $$type: '@backstage/ExtensionDataRef'; + readonly $$type: '@backstage/ExtensionDataRef'; + readonly id: TId; + readonly T: TData; + readonly config: TConfig; }; +/** @public */ +export type ExtensionDataRefToValue = + TDataRef extends ExtensionDataRef + ? ExtensionDataValue + : never; + +/** @public */ +export type AnyExtensionDataRef = ExtensionDataRef< + unknown, + string, + { optional?: true } +>; + /** @public */ export interface ConfigurableExtensionDataRef< - TId extends string, TData, + TId extends string, TConfig extends { optional?: true } = {}, > extends ExtensionDataRef { optional(): ConfigurableExtensionDataRef< - TId, TData, + TId, TData & { optional: true } >; + (t: TData): ExtensionDataValue; } /** @@ -45,36 +66,43 @@ export interface ConfigurableExtensionDataRef< */ export function createExtensionDataRef( id: string, -): ConfigurableExtensionDataRef; +): ConfigurableExtensionDataRef; /** @public */ export function createExtensionDataRef(): { with(options: { id: TId; - }): ConfigurableExtensionDataRef; + }): ConfigurableExtensionDataRef; }; export function createExtensionDataRef(id?: string): - | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef | { with(options: { id: TId; - }): ConfigurableExtensionDataRef; + }): ConfigurableExtensionDataRef; } { const createRef = (refId: TId) => - ({ - id: refId, - $$type: '@backstage/ExtensionDataRef', - config: {}, - optional() { - return { - ...this, - config: { ...this.config, optional: true }, - }; - }, - toString() { - const optional = Boolean(this.config.optional); - return `ExtensionDataRef{id=${refId},optional=${optional}}`; - }, - } as ConfigurableExtensionDataRef); + Object.assign( + (value: TData): ExtensionDataValue => ({ + $$type: '@backstage/ExtensionDataValue', + id: refId, + value, + }), + { + id: refId, + $$type: '@backstage/ExtensionDataRef', + config: {}, + optional() { + return { + ...this, + config: { ...this.config, optional: true }, + }; + }, + toString() { + const optional = Boolean(this.config.optional); + return `ExtensionDataRef{id=${refId},optional=${optional}}`; + }, + } as ConfigurableExtensionDataRef, + ); if (id) { return createRef(id); } diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts b/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts new file mode 100644 index 0000000000..e401adc675 --- /dev/null +++ b/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { createExtensionDataRef } from './createExtensionDataRef'; +import { + ExtensionInput, + LegacyExtensionInput, + createExtensionInput, +} from './createExtensionInput'; + +const stringDataRef = createExtensionDataRef().with({ id: 'str' }); +const numberDataRef = createExtensionDataRef().with({ id: 'num' }); + +function unused(..._any: any[]) {} + +describe('createExtensionInput', () => { + it('should create a regular input', () => { + const input = createExtensionInput([stringDataRef, numberDataRef]); + expect(input).toEqual({ + $$type: '@backstage/ExtensionInput', + extensionData: [stringDataRef, numberDataRef], + config: { singleton: false, optional: false }, + }); + + const x1: ExtensionInput< + typeof stringDataRef | typeof numberDataRef, + { singleton: false; optional: false } + > = input; + // @ts-expect-error + const x2: ExtensionInput< + typeof stringDataRef, + { singleton: false; optional: false } + > = input; + // @ts-expect-error + const x3: ExtensionInput< + typeof stringDataRef | typeof numberDataRef, + { singleton: true; optional: false } + > = input; + // @ts-expect-error + const x4: ExtensionInput< + typeof stringDataRef | typeof numberDataRef, + { singleton: false; optional: true } + > = input; + + unused(x1, x2, x3, x4); + }); + + it('should create a singleton input', () => { + const input = createExtensionInput([stringDataRef, numberDataRef], { + singleton: true, + }); + expect(input).toEqual({ + $$type: '@backstage/ExtensionInput', + extensionData: [stringDataRef, numberDataRef], + config: { singleton: true, optional: false }, + }); + + const x1: ExtensionInput< + typeof stringDataRef | typeof numberDataRef, + { singleton: true; optional: false } + > = input; + // @ts-expect-error + const x2: ExtensionInput< + typeof stringDataRef, + { singleton: true; optional: false } + > = input; + // @ts-expect-error + const x3: ExtensionInput< + typeof stringDataRef | typeof numberDataRef, + { singleton: false; optional: false } + > = input; + // @ts-expect-error + const x4: ExtensionInput< + typeof stringDataRef | typeof numberDataRef, + { singleton: false; optional: true } + > = input; + + unused(x1, x2, x3, x4); + }); + + it('should create an optional singleton input', () => { + const input = createExtensionInput([stringDataRef, numberDataRef], { + singleton: true, + optional: true, + }); + expect(input).toEqual({ + $$type: '@backstage/ExtensionInput', + extensionData: [stringDataRef, numberDataRef], + config: { singleton: true, optional: true }, + }); + + const x1: ExtensionInput< + typeof stringDataRef | typeof numberDataRef, + { singleton: true; optional: true } + > = input; + // @ts-expect-error + const x2: ExtensionInput< + typeof stringDataRef, + { singleton: true; optional: true } + > = input; + // @ts-expect-error + const x3: ExtensionInput< + typeof stringDataRef | typeof numberDataRef, + { singleton: false; optional: false } + > = input; + // @ts-expect-error + const x4: ExtensionInput< + typeof stringDataRef | typeof numberDataRef, + { singleton: false; optional: true } + > = input; + + unused(x1, x2, x3, x4); + }); + + it('should not allow duplicate data refs', () => { + expect(() => + createExtensionInput([stringDataRef, stringDataRef], { singleton: true }), + ).toThrow("ExtensionInput may not have duplicate data refs: 'str'"); + }); + + describe('old api', () => { + it('should create a regular input', () => { + const input = createExtensionInput({ + str: stringDataRef, + num: numberDataRef, + }); + expect(input).toEqual({ + $$type: '@backstage/ExtensionInput', + extensionData: { str: stringDataRef, num: numberDataRef }, + config: { singleton: false, optional: false }, + }); + + const x1: LegacyExtensionInput< + { str: typeof stringDataRef; num: typeof numberDataRef }, + { singleton: false; optional: false } + > = input; + // @ts-expect-error + const x2: LegacyExtensionInput< + { str: typeof numberDataRef; num: typeof stringDataRef }, // switched + { singleton: false; optional: false } + > = input; + // @ts-expect-error + const x3: LegacyExtensionInput< + { str: typeof stringDataRef; num: typeof numberDataRef }, + { singleton: true; optional: false } + > = input; + // @ts-expect-error + const x4: LegacyExtensionInput< + { str: typeof stringDataRef; num: typeof numberDataRef }, + { singleton: false; optional: true } + > = input; + + unused(x1, x2, x3, x4); + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts b/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts index 1fd8fea972..b7efb13e6e 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts @@ -15,31 +15,104 @@ */ import { AnyExtensionDataMap } from './createExtension'; +import { ExtensionDataRef } from './createExtensionDataRef'; /** @public */ export interface ExtensionInput< - TExtensionData extends AnyExtensionDataMap, + TExtensionData extends ExtensionDataRef, TConfig extends { singleton: boolean; optional: boolean }, > { $$type: '@backstage/ExtensionInput'; - extensionData: TExtensionData; + extensionData: Array; config: TConfig; } -/** @public */ +/** + * @public + * @deprecated This type will be removed. Use `ExtensionInput` instead. + */ +export interface LegacyExtensionInput< + TExtensionDataMap extends AnyExtensionDataMap, + TConfig extends { singleton: boolean; optional: boolean }, +> { + $$type: '@backstage/ExtensionInput'; + extensionData: TExtensionDataMap; + config: TConfig; +} + +/** + * @public + * @deprecated Use the following form instead: `createExtensionInput([dataRef1, dataRef2])` + */ export function createExtensionInput< - TExtensionData extends AnyExtensionDataMap, + TExtensionDataMap extends AnyExtensionDataMap, TConfig extends { singleton?: boolean; optional?: boolean }, >( - extensionData: TExtensionData, + extensionData: TExtensionDataMap, config?: TConfig, -): ExtensionInput< - TExtensionData, +): LegacyExtensionInput< + TExtensionDataMap, { singleton: TConfig['singleton'] extends true ? true : false; optional: TConfig['optional'] extends true ? true : false; } -> { +>; +/** @public */ +export function createExtensionInput< + UExtensionData extends ExtensionDataRef, + TConfig extends { singleton?: boolean; optional?: boolean }, +>( + extensionData: Array, + config?: TConfig, +): ExtensionInput< + UExtensionData, + { + singleton: TConfig['singleton'] extends true ? true : false; + optional: TConfig['optional'] extends true ? true : false; + } +>; +export function createExtensionInput< + TExtensionData extends ExtensionDataRef, + TExtensionDataMap extends AnyExtensionDataMap, + TConfig extends { singleton?: boolean; optional?: boolean }, +>( + extensionData: Array | TExtensionDataMap, + config?: TConfig, +): + | LegacyExtensionInput< + TExtensionDataMap, + { + singleton: TConfig['singleton'] extends true ? true : false; + optional: TConfig['optional'] extends true ? true : false; + } + > + | ExtensionInput< + TExtensionData, + { + singleton: TConfig['singleton'] extends true ? true : false; + optional: TConfig['optional'] extends true ? true : false; + } + > { + if (process.env.NODE_ENV !== 'production') { + if (Array.isArray(extensionData)) { + const seen = new Set(); + const duplicates = []; + for (const dataRef of extensionData) { + if (seen.has(dataRef.id)) { + duplicates.push(dataRef.id); + } else { + seen.add(dataRef.id); + } + } + if (duplicates.length > 0) { + throw new Error( + `ExtensionInput may not have duplicate data refs: '${duplicates.join( + "', '", + )}'`, + ); + } + } + } return { $$type: '@backstage/ExtensionInput', extensionData, @@ -51,5 +124,19 @@ export function createExtensionInput< ? true : false, }, - }; + } as + | LegacyExtensionInput< + TExtensionDataMap, + { + singleton: TConfig['singleton'] extends true ? true : false; + optional: TConfig['optional'] extends true ? true : false; + } + > + | ExtensionInput< + TExtensionData, + { + singleton: TConfig['singleton'] extends true ? true : false; + optional: TConfig['optional'] extends true ? true : false; + } + >; } diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 6d20368225..a17dfedc16 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -17,21 +17,27 @@ export { coreExtensionData } from './coreExtensionData'; export { createExtension, + type ExtensionDataContainer, type ExtensionDefinition, type CreateExtensionOptions, type ExtensionDataValues, type ResolvedExtensionInput, type ResolvedExtensionInputs, + type LegacyCreateExtensionOptions, type AnyExtensionInputMap, type AnyExtensionDataMap, } from './createExtension'; export { createExtensionInput, type ExtensionInput, + type LegacyExtensionInput, } from './createExtensionInput'; export { createExtensionDataRef, + type AnyExtensionDataRef, type ExtensionDataRef, + type ExtensionDataRefToValue, + type ExtensionDataValue, type ConfigurableExtensionDataRef, } from './createExtensionDataRef'; export { createPlugin, type PluginOptions } from './createPlugin'; diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts index 8970cfe168..e1bfcbfc11 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -17,14 +17,54 @@ import { ExtensionDefinition } from './createExtension'; import { resolveExtensionDefinition } from './resolveExtensionDefinition'; -const baseDef = { - $$type: '@backstage/ExtensionDefinition', - version: 'v1', - attachTo: { id: '', input: '' }, - disabled: false, -}; - describe('resolveExtensionDefinition', () => { + const baseDef = { + $$type: '@backstage/ExtensionDefinition', + version: 'v2', + attachTo: { id: '', input: '' }, + disabled: false, + }; + + it.each([ + [{ namespace: 'ns' }, 'ns'], + [{ namespace: 'n' }, 'n'], + [{ namespace: 'ns', name: 'n' }, 'ns/n'], + [{ kind: 'k', namespace: 'ns' }, 'k:ns'], + [{ kind: 'k', namespace: 'ns', name: 'n' }, 'k:ns/n'], + ])(`should resolve extension IDs %s`, (definition, expected) => { + const resolved = resolveExtensionDefinition({ + ...baseDef, + ...definition, + } as ExtensionDefinition); + expect(resolved.id).toBe(expected); + expect(String(resolved)).toBe(`Extension{id=${expected}}`); + }); + + it('should fail to resolve extension ID without namespace', () => { + expect(() => + resolveExtensionDefinition({ + ...baseDef, + kind: 'k', + } as ExtensionDefinition), + ).toThrow( + 'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=k namespace=undefined name=undefined', + ); + expect(() => + resolveExtensionDefinition(baseDef as ExtensionDefinition), + ).toThrow( + 'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=undefined namespace=undefined name=undefined', + ); + }); +}); + +describe('old resolveExtensionDefinition', () => { + const baseDef = { + $$type: '@backstage/ExtensionDefinition', + version: 'v1', + attachTo: { id: '', input: '' }, + disabled: false, + }; + it.each([ [{ namespace: 'ns' }, 'ns'], [{ namespace: 'n' }, 'n'], diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index eddd923702..18b6f0a653 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -24,6 +24,11 @@ import { toInternalExtensionDefinition, } from './createExtension'; import { PortableSchema } from '../schema'; +import { ExtensionInput } from './createExtensionInput'; +import { + AnyExtensionDataRef, + ExtensionDataValue, +} from './createExtensionDataRef'; /** @public */ export interface Extension { @@ -35,17 +40,42 @@ export interface Extension { } /** @internal */ -export interface InternalExtension - extends Extension { - readonly version: 'v1'; - readonly inputs: AnyExtensionInputMap; - readonly output: AnyExtensionDataMap; - factory(options: { - node: AppNode; - config: TConfig; - inputs: ResolvedExtensionInputs; - }): ExtensionDataValues; -} +export type InternalExtension = Extension< + TConfig, + TConfigInput +> & + ( + | { + readonly version: 'v1'; + readonly inputs: AnyExtensionInputMap; + readonly output: AnyExtensionDataMap; + factory(options: { + node: AppNode; + config: TConfig; + inputs: ResolvedExtensionInputs; + }): ExtensionDataValues; + } + | { + readonly version: 'v2'; + readonly inputs: { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }; + readonly output: Array; + factory(options: { + node: AppNode; + config: TConfig; + inputs: ResolvedExtensionInputs<{ + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }>; + }): Iterable>; + } + ); /** @internal */ export function toInternalExtension( @@ -57,10 +87,9 @@ export function toInternalExtension( `Invalid extension instance, bad type '${internal.$$type}'`, ); } - if (internal.version !== 'v1') { - throw new Error( - `Invalid extension instance, bad version '${internal.version}'`, - ); + const version = internal.version; + if (version !== 'v1' && version !== 'v2') { + throw new Error(`Invalid extension instance, bad version '${version}'`); } return internal; } @@ -87,10 +116,10 @@ export function resolveExtensionDefinition( return { ...rest, $$type: '@backstage/Extension', - version: 'v1', + version: internalDefinition.version, id, toString() { return `Extension{id=${id}}`; }, - } as Extension; + } as InternalExtension; } diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 69c4b09d06..81b74c9fce 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -19,6 +19,7 @@ import { MemoryRouter, Link } from 'react-router-dom'; import { RenderResult, render } from '@testing-library/react'; import { createSpecializedApp } from '@backstage/frontend-app-api'; import { + ExtensionDataValue, ExtensionDefinition, IconComponent, RouteRef, @@ -93,21 +94,48 @@ export class ExtensionTester { options?: { config?: TConfigInput }, ): ExtensionTester { const tester = new ExtensionTester(); - const { output, factory, ...rest } = toInternalExtensionDefinition(subject); + const internal = toInternalExtensionDefinition(subject); + // attaching to app/routes to render as index route - const extension = createExtension({ - ...rest, - attachTo: { id: 'app/routes', input: 'routes' }, - output: { - ...output, - path: coreExtensionData.routePath, - }, - factory: params => ({ - ...factory(params), - path: '/', - }), - }); - tester.add(extension, options as TConfigInput & {}); + if (internal.version === 'v1') { + tester.add( + createExtension({ + ...internal, + attachTo: { id: 'app/routes', input: 'routes' }, + output: { + ...internal.output, + path: coreExtensionData.routePath, + }, + factory: params => ({ + ...internal.factory(params as any), + path: '/', + }), + }), + options as TConfigInput & {}, + ); + } else if (internal.version === 'v2') { + tester.add( + createExtension({ + ...internal, + attachTo: { id: 'app/routes', input: 'routes' }, + output: internal.output.find( + ref => ref.id === coreExtensionData.routePath.id, + ) + ? internal.output + : [...internal.output, coreExtensionData.routePath], + factory: params => { + const parentOutput = Array.from( + internal.factory(params) as Iterable< + ExtensionDataValue + >, + ).filter(val => val.id !== coreExtensionData.routePath.id); + + return [...parentOutput, coreExtensionData.routePath('/')]; + }, + }), + options as TConfigInput & {}, + ); + } return tester; } diff --git a/plugins/catalog-react/api-report-alpha.md b/plugins/catalog-react/api-report-alpha.md index 1ffa67068f..206c0160e2 100644 --- a/plugins/catalog-react/api-report-alpha.md +++ b/plugins/catalog-react/api-report-alpha.md @@ -18,18 +18,18 @@ import { TranslationRef } from '@backstage/core-plugin-api/alpha'; // @alpha (undocumented) export const catalogExtensionData: { entityContentTitle: ConfigurableExtensionDataRef< - 'catalog.entity-content-title', string, + 'catalog.entity-content-title', {} >; entityFilterFunction: ConfigurableExtensionDataRef< - 'catalog.entity-filter-function', (entity: Entity) => boolean, + 'catalog.entity-filter-function', {} >; entityFilterExpression: ConfigurableExtensionDataRef< - 'catalog.entity-filter-expression', string, + 'catalog.entity-filter-expression', {} >; }; diff --git a/plugins/home/api-report-alpha.md b/plugins/home/api-report-alpha.md index 1dd3da96be..eb693a8e6f 100644 --- a/plugins/home/api-report-alpha.md +++ b/plugins/home/api-report-alpha.md @@ -12,8 +12,8 @@ export default _default; // @alpha (undocumented) export const titleExtensionDataRef: ConfigurableExtensionDataRef< - 'title', string, + 'title', {} >; diff --git a/plugins/search-react/api-report-alpha.md b/plugins/search-react/api-report-alpha.md index 3da9ac23a4..c6e011fe41 100644 --- a/plugins/search-react/api-report-alpha.md +++ b/plugins/search-react/api-report-alpha.md @@ -31,11 +31,11 @@ export function createSearchResultListItemExtension< export namespace createSearchResultListItemExtension { const // (undocumented) itemDataRef: ConfigurableExtensionDataRef< - 'search.search-result-list-item.item', { predicate?: SearchResultItemExtensionPredicate | undefined; component: SearchResultItemExtensionComponent; }, + 'search.search-result-list-item.item', {} >; }