From 0d9c38f1b806e25b3a85bf56f2c14feef8fc8a1c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 26 Jul 2024 15:24:21 +0200 Subject: [PATCH] frontend-plugin-api: add new version of createExtension that avoids data maps Signed-off-by: Patrik Oldsberg --- .../src/wiring/createExtension.test.ts | 113 ++++++++-- .../src/wiring/createExtension.ts | 193 ++++++++++++++++-- .../src/wiring/createExtensionDataRef.ts | 7 + 3 files changed, 285 insertions(+), 28 deletions(-) diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index 5b737746c2..febc7ec133 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({ @@ -42,11 +43,11 @@ describe('createExtension', () => { expect(extension.namespace).toBe('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({ @@ -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; @@ -304,7 +305,7 @@ describe('createExtension', () => { }, }, output: { - foo: stringData, + foo: stringDataRef, }, factory({ config }) { const a1: string = config.foo; @@ -355,4 +356,88 @@ describe('createExtension', () => { return extension.configSchema?.parse({}); }).toThrow("Missing required value at 'foo'"); }); + + it('should new form of inputs and outputs', () => { + 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))]; + }, + }); + + // Double output + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef], + // @ts-expect-error + factory() { + return [stringDataRef('hello'), stringDataRef('hello')]; + }, + }); + + // Missing output + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef], + // @ts-expect-error + factory() { + return [stringDataRef('hello')]; + }, + }); + + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef], + factory() { + return [stringDataRef('hello'), numberDataRef(4)]; + }, + }); + + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef.optional()], + factory() { + return [stringDataRef('hello'), numberDataRef(4)]; + }, + }); + + createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef.optional()], + factory() { + return [stringDataRef('hello')]; + }, + }); + + expect(true).toBe(true); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index cc2bec91c9..c514750a84 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -17,17 +17,26 @@ import { AppNode } from '../apis'; import { PortableSchema, createSchemaFromZod } from '../schema'; import { Expand } from '../types'; -import { ExtensionDataRef } from './createExtensionDataRef'; +import { + AnyExtensionDataRef, + ExtensionDataRef, + ExtensionDataValue, +} from './createExtensionDataRef'; import { ExtensionInput } 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 */ export type AnyExtensionInputMap = { [inputName in string]: ExtensionInput< + AnyExtensionDataRef, AnyExtensionDataMap, { optional: boolean; singleton: boolean } >; @@ -36,6 +45,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,22 +61,41 @@ 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< + TExtensionData extends AnyExtensionDataMap | AnyExtensionDataRef, +> = [TExtensionData] extends [AnyExtensionDataRef] + ? { + node: AppNode; + } & ExtensionDataContainer + : TExtensionData 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 }, > = { [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] ? Array>> @@ -77,8 +106,11 @@ export type ResolvedExtensionInputs< >; }; -/** @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 +143,61 @@ export interface CreateExtensionOptions< }): Expand>; } +// TODO(Rugvip): This creates a permutation of all possible output tuples, +// taking the optionality into account. It's not optimal, since it might hurt +// performance for large outputs, doesn't provide a very clear error message, +// and doesn't allow us to refactor to allow for a generator. +/** @public */ +type ExtensionFactoryOutput< + URef extends AnyExtensionDataRef, + TPickRef extends AnyExtensionDataRef = URef, +> = [URef] extends [never] + ? [] + : TPickRef extends ExtensionDataRef + ? + | (IConfig['optional'] extends true + ? ExtensionFactoryOutput> + : never) + | [ + ExtensionDataValue, + ...ExtensionFactoryOutput>, + ] + : never; + +/** @public */ +export type CreateExtensionOptions< + UOutput extends AnyExtensionDataRef, + TInputs extends AnyExtensionInputMap, + TConfig, + TConfigInput, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, +> = { + 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>; + }): ExtensionFactoryOutput; +}; + /** @public */ export interface ExtensionDefinition { $$type: '@backstage/ExtensionDefinition'; @@ -127,12 +214,12 @@ export interface InternalExtensionDefinition extends ExtensionDefinition { readonly version: 'v1'; readonly inputs: AnyExtensionInputMap; - readonly output: AnyExtensionDataMap; + readonly output: AnyExtensionDataMap | Array; factory(context: { node: AppNode; config: TConfig; inputs: ResolvedExtensionInputs; - }): ExtensionDataValues; + }): ExtensionDataValues | Iterable>; } /** @internal */ @@ -157,6 +244,46 @@ export function toInternalExtensionDefinition( } /** @public */ +export function createExtension< + UOutput extends AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + never, + { optional: boolean; singleton: boolean } + >; + }, + TConfig, + TConfigInput, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, +>( + options: CreateExtensionOptions< + UOutput, + TInputs, + 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; + }> + >) +>; +/** + * @public + * @deprecated - use the array format of `output` instead, see TODO-doc-link + */ export function createExtension< TOutput extends AnyExtensionDataMap, TInputs extends AnyExtensionInputMap, @@ -164,7 +291,7 @@ export function createExtension< TConfigInput, TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, >( - options: CreateExtensionOptions< + options: LegacyCreateExtensionOptions< TOutput, TInputs, TConfig, @@ -186,6 +313,44 @@ export function createExtension< [key in keyof TConfigSchema]: ReturnType; }> >) +>; +export function createExtension< + UOutput extends AnyExtensionDataRef, + TInputs extends AnyExtensionInputMap, + TConfig, + TConfigInput, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, +>( + options: + | CreateExtensionOptions< + UOutput, + TInputs, + TConfig, + TConfigInput, + TConfigSchema + > + | LegacyCreateExtensionOptions< + AnyExtensionDataMap, + TInputs, + 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) { diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts index 7e0ff7587e..f4b348ae51 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts @@ -33,6 +33,13 @@ export type ExtensionDataRef< readonly config: TConfig; }; +/** @public */ +export type AnyExtensionDataRef = ExtensionDataRef< + unknown, + string, + { optional?: true } +>; + /** @public */ export interface ConfigurableExtensionDataRef< TData,