diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 04d7f15240..df097bd843 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -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 BlueprintParamsDefiner + ? IParams | BlueprintParamsFactory + : + | TParams + | BlueprintParamsFactory>; + /** @public */ export type ExtensionDefinition< T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters, @@ -183,6 +194,7 @@ export type ExtensionDefinition< { optional: boolean; singleton: boolean } >; }, + TParamsReturn extends AnyParamsInput>, >( args: Expand< { @@ -200,14 +212,24 @@ export type ExtensionDefinition< }; }; factory?( - originalFactory: ( + originalFactory: < + TFactoryParamsReturn extends AnyParamsInput< + NonNullable + >, + >( context?: Expand< { config?: T['config']; inputs?: ResolveInputValueOverrides>; } & ([T['params']] extends [never] ? {} - : { params?: Partial }) + : { + 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())`' + : Partial; + }) >, ) => ExtensionDataContainer>, context: { @@ -223,7 +245,13 @@ export type ExtensionDefinition< ): Iterable; } & ([T['params']] extends [never] ? {} - : { params?: Partial }) + : { + 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())`' + : Partial; + }) > & VerifyExtensionFactoryOutput< AnyExtensionDataRef extends UNewOutput diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index a71147716e..faec493092 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -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({ diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 57848a7980..6f60950eb3 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -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; +export type BlueprintParamsFactory = ( + define: TDefiner, +) => ReturnType; + +export type BlueprintParams = { + $$type: '@backstage/BlueprintParams'; + T: T; +}; + +const OpaqueBlueprintParams = OpaqueType.create<{ + public: BlueprintParams; + versions: { + version: 'v1'; + params: object; + }; +}>({ + type: '@backstage/BlueprintParams', + versions: ['v1'], +}); + +export function createExtensionBlueprintParams( + params: T, +): BlueprintParams { + 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['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 BlueprintParamsDefiner + ? IParams | BlueprintParamsFactory + : + | TParams + | BlueprintParamsFactory>; + /** * @public */ export interface ExtensionBlueprint< + // TParamsMapper extends (params: any) => object, T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters, > { dataRefs: T['dataRefs']; - make(args: { + make< + TNewName extends string | undefined, + TParamsReturn extends AnyParamsInput, + >(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: `.make({ params: define => define() })`' + : 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: >( + 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())`' + : TParamsReturn, context?: { config?: T['config']; inputs?: ResolveInputValueOverrides>; @@ -205,6 +266,76 @@ export interface ExtensionBlueprint< }>; } +function unwrapParamsFactory( + // Allow `Function` because `typeof === 'function'` allows it, but in practice this should always be a param factory + params: BlueprintParamsFactory | Function, + defineParams: BlueprintParamsDefiner, + kind: string, +): TParams { + const paramDefinition = ( + params as BlueprintParamsFactory + )(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( + params: object | BlueprintParamsFactory | string, + ctx: { node: AppNode; [ctxParamsSymbol]?: any }, + defineParams: BlueprintParamsDefiner | undefined, + kind: string, +): TParams { + const overrideParams = ctx[ctxParamsSymbol] as + | object + | BlueprintParamsFactory + | 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( + params, + createExtensionBlueprintParams, + kind, + ) + : (params as TParams); + const overrides = + typeof overrideParams === 'function' + ? unwrapParamsFactory( + overrideParams, + createExtensionBlueprintParams, + kind, + ) + : (overrideParams as Partial); + + 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>, }) as ExtensionDefinition; @@ -295,7 +430,7 @@ export function createExtensionBlueprint< (innerParams, innerContext) => { return createExtensionDataContainer( options.factory( - { ...innerParams, ...(ctx as any)[ctxParamsSymbol] }, + unwrapParams(innerParams, ctx, defineParams, options.kind), { apis, node, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts index b25a7b9375..a74d908024 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -113,6 +113,7 @@ describe('ResolveExtensionId', () => { kind: TKind; name: TName; output: any; + params: never; }>; const id1: 'k:ns' = {} as ResolveExtensionId< NamedExtension<'k', undefined>, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index 7e771c6c2c..c3ff381fdc 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -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