frontend-plugin-api: refactor to use two disctict internal extension definitions

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-07-26 17:07:28 +02:00
parent 760d99b138
commit e7a720ac2c
2 changed files with 154 additions and 117 deletions
@@ -40,7 +40,7 @@ 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 often point at the property that is problematic.
@@ -177,7 +177,7 @@ describe('createExtension', () => {
foo: 'bar',
}),
});
expect(extension.namespace).toBe('test');
expect(extension).toMatchObject({ version: 'v1', namespace: 'test' });
createExtension({
...baseConfig,
@@ -287,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}',
);
@@ -325,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}',
);
@@ -358,98 +358,108 @@ describe('createExtension', () => {
});
it('should support new form of outputs', () => {
// @ts-expect-error
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef],
factory() {
return []; // Missing all outputs
},
});
expect(
// @ts-expect-error
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef],
factory() {
return []; // Missing all outputs
},
}),
).toMatchObject({ version: 'v2' });
// @ts-expect-error
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef],
factory() {
return [stringDataRef('hello')]; // Missing number output
},
});
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
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef],
factory() {
return [stringDataRef('hello'), stringDataRef('hello')];
},
});
expect(
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef],
factory() {
return [stringDataRef('hello'), stringDataRef('hello')];
},
}),
).toMatchObject({ version: 'v2' });
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef],
factory() {
return [stringDataRef('hello'), numberDataRef(4)];
},
});
expect(
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef],
factory() {
return [stringDataRef('hello'), numberDataRef(4)];
},
}),
).toMatchObject({ version: 'v2' });
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef.optional()],
factory() {
return [stringDataRef('hello'), numberDataRef(4)];
},
});
expect(
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef.optional()],
factory() {
return [stringDataRef('hello'), numberDataRef(4)];
},
}),
).toMatchObject({ version: 'v2' });
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
},
});
expect(true).toBe(true);
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', () => {
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);
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
inputs.header?.get(numberDataRef);
// @ts-expect-error
const x1: string = headerStr; // string | undefined
// @ts-expect-error
const x1: string = headerStr; // string | undefined
unused(x1);
unused(x1);
return [stringDataRef(contentStr.repeat(contentNum))];
},
});
expect(true).toBe(true);
return [stringDataRef(contentStr.repeat(contentNum))];
},
}),
).toMatchObject({ version: 'v2' });
});
});
@@ -22,7 +22,7 @@ import {
ExtensionDataRef,
ExtensionDataValue,
} from './createExtensionDataRef';
import { ExtensionInput } from './createExtensionInput';
import { ExtensionInput, LegacyExtensionInput } from './createExtensionInput';
import { z } from 'zod';
/**
@@ -33,10 +33,12 @@ export type AnyExtensionDataMap = {
[name in string]: AnyExtensionDataRef;
};
/** @public */
/**
* @public
* @deprecated This type will be removed.
*/
export type AnyExtensionInputMap = {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
[inputName in string]: LegacyExtensionInput<
AnyExtensionDataMap,
{ optional: boolean; singleton: boolean }
>;
@@ -95,7 +97,7 @@ export type ResolvedExtensionInput<
* @public
*/
export type ResolvedExtensionInputs<
TInputs extends { [name in string]: ExtensionInput<any, any, any> },
TInputs extends { [name in string]: ExtensionInput<any, any> },
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]['extensionData']>>>
@@ -146,7 +148,12 @@ export interface LegacyCreateExtensionOptions<
/** @public */
export type CreateExtensionOptions<
UOutput extends AnyExtensionDataRef,
TInputs extends AnyExtensionInputMap,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
TConfig,
TConfigInput,
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
@@ -205,17 +212,40 @@ export interface ExtensionDefinition<TConfig, TConfigInput = TConfig> {
}
/** @internal */
export interface InternalExtensionDefinition<TConfig, TConfigInput>
extends ExtensionDefinition<TConfig, TConfigInput> {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap | Array<AnyExtensionDataRef>;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<any>;
}): ExtensionDataValues<any> | Iterable<ExtensionDataValue<any, any>>;
}
export type InternalExtensionDefinition<TConfig, TConfigInput> =
ExtensionDefinition<TConfig, TConfigInput> &
(
| {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<AnyExtensionInputMap>;
}): ExtensionDataValues<any>;
}
| {
readonly version: 'v2';
readonly inputs: {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
};
readonly output: Array<AnyExtensionDataRef>;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
}): Iterable<ExtensionDataValue<any, any>>;
}
);
/** @internal */
export function toInternalExtensionDefinition<TConfig, TConfigInput>(
@@ -230,9 +260,10 @@ export function toInternalExtensionDefinition<TConfig, TConfigInput>(
`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;
@@ -244,7 +275,6 @@ export function createExtension<
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
never,
{ optional: boolean; singleton: boolean }
>;
},
@@ -313,7 +343,13 @@ export function createExtension<
>;
export function createExtension<
UOutput extends AnyExtensionDataRef,
TInputs extends AnyExtensionInputMap,
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 },
@@ -330,7 +366,7 @@ export function createExtension<
>
| LegacyCreateExtensionOptions<
AnyExtensionDataMap,
TInputs,
TLegacyInputs,
TConfig,
TConfigInput,
TConfigSchema
@@ -367,7 +403,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,
@@ -376,16 +412,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<ResolvedExtensionInputs<TInputs>>,
config: config as TConfig & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
...rest,
});
},
factory: options.factory,
toString() {
const parts: string[] = [];
if (options.kind) {