frontend-plugin-api: initial implementation of advanced blueprint params types

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-07-28 14:53:55 +02:00
parent f318a35655
commit 39e8111a30
5 changed files with 199 additions and 14 deletions
@@ -30,6 +30,10 @@ import { z } from 'zod';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { OpaqueExtensionDefinition } from '@internal/frontend';
import { ExtensionDataContainer } from './types';
import {
BlueprintParamsDefiner,
BlueprintParamsFactory,
} from './createExtensionBlueprint';
/**
* This symbol is used to pass parameter overrides from the extension override to the blueprint factory
@@ -161,9 +165,16 @@ export type ExtensionDefinitionParameters = {
{ optional: boolean; singleton: boolean }
>;
};
params?: object;
params?: object | BlueprintParamsDefiner;
};
type AnyParamsInput<TParams extends object | BlueprintParamsDefiner> =
TParams extends BlueprintParamsDefiner<infer IParams>
? IParams | BlueprintParamsFactory<TParams>
:
| TParams
| BlueprintParamsFactory<BlueprintParamsDefiner<TParams, TParams>>;
/** @public */
export type ExtensionDefinition<
T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters,
@@ -183,6 +194,7 @@ export type ExtensionDefinition<
{ optional: boolean; singleton: boolean }
>;
},
TParamsReturn extends AnyParamsInput<NonNullable<T['params']>>,
>(
args: Expand<
{
@@ -200,14 +212,24 @@ export type ExtensionDefinition<
};
};
factory?(
originalFactory: (
originalFactory: <
TFactoryParamsReturn extends AnyParamsInput<
NonNullable<T['params']>
>,
>(
context?: Expand<
{
config?: T['config'];
inputs?: ResolveInputValueOverrides<NonNullable<T['inputs']>>;
} & ([T['params']] extends [never]
? {}
: { params?: Partial<T['params']> })
: {
params?: TFactoryParamsReturn extends BlueprintParamsDefiner
? TFactoryParamsReturn
: T['params'] extends BlueprintParamsDefiner
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(define => define(<params>))`'
: Partial<T['params']>;
})
>,
) => ExtensionDataContainer<NonNullable<T['output']>>,
context: {
@@ -223,7 +245,13 @@ export type ExtensionDefinition<
): Iterable<UFactoryOutput>;
} & ([T['params']] extends [never]
? {}
: { params?: Partial<T['params']> })
: {
params?: TParamsReturn extends BlueprintParamsDefiner
? TParamsReturn
: T['params'] extends BlueprintParamsDefiner
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(define => define(<params>))`'
: Partial<T['params']>;
})
> &
VerifyExtensionFactoryOutput<
AnyExtensionDataRef extends UNewOutput
@@ -1380,6 +1380,16 @@ describe('createExtensionBlueprint', () => {
}),
});
TestExtensionBlueprint.make({
params: define =>
define({
a: 'x',
b: 'x',
// @ts-expect-error extra param
c: 'y',
}),
});
const extension = TestExtensionBlueprint.make({
params: define =>
define({
@@ -1427,6 +1437,16 @@ describe('createExtensionBlueprint', () => {
}),
});
extension.override({
params: define =>
define({
a: 'z',
b: 'z',
// @ts-expect-error extra param
c: 'w',
}),
});
const override = extension.override({
params: define =>
define({
@@ -16,6 +16,7 @@
import { ApiHolder, AppNode } from '../apis';
import { Expand } from '@backstage/types';
import { OpaqueType } from '@internal/opaque';
import {
ExtensionAttachToSpec,
ExtensionDefinition,
@@ -37,13 +38,43 @@ import {
} from './resolveInputOverrides';
import { ExtensionDataContainer } from './types';
export type BlueprintParamsDefiner<
TParams extends object = object,
TInput = any,
> = (params: TInput) => BlueprintParams<TParams>;
export type BlueprintParamsFactory<TDefiner extends BlueprintParamsDefiner> = (
define: TDefiner,
) => ReturnType<TDefiner>;
export type BlueprintParams<T extends object = object> = {
$$type: '@backstage/BlueprintParams';
T: T;
};
const OpaqueBlueprintParams = OpaqueType.create<{
public: BlueprintParams;
versions: {
version: 'v1';
params: object;
};
}>({
type: '@backstage/BlueprintParams',
versions: ['v1'],
});
export function createExtensionBlueprintParams<T extends object = object>(
params: T,
): BlueprintParams<T> {
return OpaqueBlueprintParams.createInstance('v1', { T: null as any, params });
}
/**
* @public
*/
export type CreateExtensionBlueprintOptions<
TKind extends string,
TName extends string | undefined,
TParams,
TParams extends object | BlueprintParamsDefiner,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
@@ -64,8 +95,13 @@ export type CreateExtensionBlueprintOptions<
config?: {
schema: TConfigSchema;
};
defineParams?: TParams extends BlueprintParamsDefiner
? TParams
: 'The params option must be a function if provided, see the docs for details';
factory(
params: TParams,
params: TParams extends BlueprintParamsDefiner
? ReturnType<TParams>['T']
: TParams,
context: {
node: AppNode;
apis: ApiHolder;
@@ -83,7 +119,7 @@ export type CreateExtensionBlueprintOptions<
export type ExtensionBlueprintParameters = {
kind: string;
name?: string;
params?: object;
params: object | BlueprintParamsDefiner;
configInput?: { [K in string]: any };
config?: { [K in string]: any };
output?: AnyExtensionDataRef;
@@ -96,19 +132,40 @@ export type ExtensionBlueprintParameters = {
dataRefs?: { [name in string]: AnyExtensionDataRef };
};
/**
* Represents any form of params input that can be passed to a blueprint.
* This also includes the invalid form of passing a plain params object to a blueprint that uses a definition callback.
*
* @internal
*/
type AnyParamsInput<TParams extends object | BlueprintParamsDefiner> =
TParams extends BlueprintParamsDefiner<infer IParams>
? IParams | BlueprintParamsFactory<TParams>
:
| TParams
| BlueprintParamsFactory<BlueprintParamsDefiner<TParams, TParams>>;
/**
* @public
*/
export interface ExtensionBlueprint<
// TParamsMapper extends (params: any) => object,
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
> {
dataRefs: T['dataRefs'];
make<TNewName extends string | undefined>(args: {
make<
TNewName extends string | undefined,
TParamsReturn extends AnyParamsInput<T['params']>,
>(args: {
name?: TNewName;
attachTo?: ExtensionAttachToSpec;
disabled?: boolean;
params: T['params'];
params: TParamsReturn extends BlueprintParamsDefiner
? TParamsReturn
: T['params'] extends BlueprintParamsDefiner
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `<blueprint>.make({ params: define => define(<params>) })`'
: TParamsReturn;
}): ExtensionDefinition<{
kind: T['kind'];
name: string | undefined extends TNewName ? T['name'] : TNewName;
@@ -154,8 +211,12 @@ export interface ExtensionBlueprint<
};
};
factory(
originalFactory: (
params: T['params'],
originalFactory: <TParamsReturn extends AnyParamsInput<T['params']>>(
params: TParamsReturn extends BlueprintParamsDefiner
? TParamsReturn
: T['params'] extends BlueprintParamsDefiner
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(define => define(<params>))`'
: TParamsReturn,
context?: {
config?: T['config'];
inputs?: ResolveInputValueOverrides<NonNullable<T['inputs']>>;
@@ -205,6 +266,76 @@ export interface ExtensionBlueprint<
}>;
}
function unwrapParamsFactory<TParams extends object>(
// Allow `Function` because `typeof <object> === 'function'` allows it, but in practice this should always be a param factory
params: BlueprintParamsFactory<BlueprintParamsDefiner> | Function,
defineParams: BlueprintParamsDefiner,
kind: string,
): TParams {
const paramDefinition = (
params as BlueprintParamsFactory<BlueprintParamsDefiner>
)(defineParams);
try {
return OpaqueBlueprintParams.toInternal(paramDefinition).params as TParams;
} catch (e) {
throw new TypeError(
`Invalid invocation of blueprint with kind '${kind}', the parameter definition callback function did not return a valid parameter definition object; Caused by: ${e.message}`,
);
}
}
function unwrapParams<TParams extends object>(
params: object | BlueprintParamsFactory<BlueprintParamsDefiner> | string,
ctx: { node: AppNode; [ctxParamsSymbol]?: any },
defineParams: BlueprintParamsDefiner | undefined,
kind: string,
): TParams {
const overrideParams = ctx[ctxParamsSymbol] as
| object
| BlueprintParamsFactory<BlueprintParamsDefiner>
| undefined;
if (defineParams) {
if (overrideParams) {
if (typeof overrideParams !== 'function') {
throw new TypeError(
`Invalid extension override of blueprint with kind '${kind}', the override params were passed as a plain object, but this blueprint requires them to be passed in callback form`,
);
}
return unwrapParamsFactory(overrideParams, defineParams, kind);
}
if (typeof params !== 'function') {
throw new TypeError(
`Invalid invocation of blueprint with kind '${kind}', the parameters where passed as a plain object, but this blueprint requires them to be passed in callback form`,
);
}
return unwrapParamsFactory(params, defineParams, kind);
}
const base =
typeof params === 'function'
? unwrapParamsFactory<TParams>(
params,
createExtensionBlueprintParams,
kind,
)
: (params as TParams);
const overrides =
typeof overrideParams === 'function'
? unwrapParamsFactory<TParams>(
overrideParams,
createExtensionBlueprintParams,
kind,
)
: (overrideParams as Partial<TParams>);
return {
...base,
...overrides,
};
}
/**
* A simpler replacement for wrapping up `createExtension` inside a kind or type. This allows for a cleaner API for creating
* types and instances of those types.
@@ -212,7 +343,7 @@ export interface ExtensionBlueprint<
* @public
*/
export function createExtensionBlueprint<
TParams extends object,
TParams extends object | BlueprintParamsDefiner,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
@@ -254,6 +385,10 @@ export function createExtensionBlueprint<
>;
dataRefs: TDataRefs;
}> {
const defineParams = options.defineParams as
| BlueprintParamsDefiner
| undefined;
return {
dataRefs: options.dataRefs,
make(args) {
@@ -267,7 +402,7 @@ export function createExtensionBlueprint<
config: options.config,
factory: ctx =>
options.factory(
{ ...args.params, ...(ctx as any)[ctxParamsSymbol] },
unwrapParams(args.params, ctx, defineParams, options.kind),
ctx,
) as Iterable<ExtensionDataValue<any, any>>,
}) as ExtensionDefinition;
@@ -295,7 +430,7 @@ export function createExtensionBlueprint<
(innerParams, innerContext) => {
return createExtensionDataContainer<UOutput>(
options.factory(
{ ...innerParams, ...(ctx as any)[ctxParamsSymbol] },
unwrapParams(innerParams, ctx, defineParams, options.kind),
{
apis,
node,
@@ -113,6 +113,7 @@ describe('ResolveExtensionId', () => {
kind: TKind;
name: TName;
output: any;
params: never;
}>;
const id1: 'k:ns' = {} as ResolveExtensionId<
NamedExtension<'k', undefined>,
@@ -116,6 +116,7 @@ export type ResolveExtensionId<
> = TExtension extends ExtensionDefinition<{
kind: infer IKind extends string | undefined;
name: infer IName extends string | undefined;
params: any;
}>
? [string] extends [IKind | IName]
? never