diff --git a/.changeset/sweet-oranges-buy.md b/.changeset/sweet-oranges-buy.md new file mode 100644 index 0000000000..d9f3343e3d --- /dev/null +++ b/.changeset/sweet-oranges-buy.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added support to be able to define `zod` config schema in Blueprints, with built in schema merging from the Blueprint and the extension instances. diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index d6a91c0c99..45ed51b415 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -511,9 +511,12 @@ export function createComponentRef(options: { export function createExtension< TOutput extends AnyExtensionDataMap, TInputs extends AnyExtensionInputMap, - TConfig = never, + TConfig, + TConfigSchema extends { + [key: string]: (zImpl: typeof z) => z.ZodType; + }, >( - options: CreateExtensionOptions, + options: CreateExtensionOptions, ): ExtensionDefinition; // @public @@ -521,24 +524,38 @@ export function createExtensionBlueprint< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, - TConfig, + TConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, TDataRefs extends AnyExtensionDataMap = never, >( options: CreateExtensionBlueprintOptions< TParams, TInputs, TOutput, - TConfig, + TConfigSchema, TDataRefs >, -): ExtensionBlueprint; +): ExtensionBlueprint< + TParams, + TInputs, + TOutput, + string extends keyof TConfigSchema + ? {} + : { + [key in keyof TConfigSchema]: z.infer>; + }, + TDataRefs +>; // @public (undocumented) export interface CreateExtensionBlueprintOptions< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, - TConfig, + TConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, TDataRefs extends AnyExtensionDataMap, > { // (undocumented) @@ -547,7 +564,9 @@ export interface CreateExtensionBlueprintOptions< input: string; }; // (undocumented) - configSchema?: PortableSchema; + config?: { + schema: TConfigSchema; + }; // (undocumented) dataRefs?: TDataRefs; // (undocumented) @@ -557,7 +576,9 @@ export interface CreateExtensionBlueprintOptions< params: TParams, context: { node: AppNode; - config: TConfig; + config: { + [key in keyof TConfigSchema]: z.infer>; + }; inputs: Expand>; }, ): Expand>; @@ -606,6 +627,9 @@ export interface CreateExtensionOptions< TOutput extends AnyExtensionDataMap, TInputs extends AnyExtensionInputMap, TConfig, + TConfigSchema extends { + [key: string]: (zImpl: typeof z) => z.ZodType; + }, > { // (undocumented) attachTo: { @@ -613,13 +637,19 @@ export interface CreateExtensionOptions< input: string; }; // (undocumented) + config?: { + schema: TConfigSchema; + }; + // @deprecated (undocumented) configSchema?: PortableSchema; // (undocumented) disabled?: boolean; // (undocumented) factory(context: { node: AppNode; - config: TConfig; + config: TConfig & { + [key in keyof TConfigSchema]: z.infer>; + }; inputs: Expand>; }): Expand>; // (undocumented) @@ -696,7 +726,7 @@ export function createNavLogoExtension(options: { namespace?: string; logoIcon: JSX.Element; logoFull: JSX.Element; -}): ExtensionDefinition; +}): ExtensionDefinition; // @public (undocumented) export namespace createNavLogoExtension { @@ -804,7 +834,7 @@ export namespace createRouterExtension { >; } -// @public (undocumented) +// @public @deprecated (undocumented) export function createSchemaFromZod( schemaCreator: (zImpl: typeof z) => ZodSchema, ): PortableSchema; @@ -851,7 +881,7 @@ export function createSubRouteRef< // @public (undocumented) export function createThemeExtension( theme: AppTheme, -): ExtensionDefinition; +): ExtensionDefinition; // @public (undocumented) export namespace createThemeExtension { @@ -867,7 +897,7 @@ export namespace createThemeExtension { export function createTranslationExtension(options: { name?: string; resource: TranslationResource | TranslationMessages; -}): ExtensionDefinition; +}): ExtensionDefinition; // @public (undocumented) export namespace createTranslationExtension { @@ -926,12 +956,18 @@ export interface ExtensionBlueprint< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, - TConfig, + TConfig extends { + [key in string]: unknown; + }, TDataRefs extends AnyExtensionDataMap, > { // (undocumented) dataRefs: TDataRefs; - make( + make< + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + >( args: { namespace?: string; name?: string; @@ -942,7 +978,12 @@ export interface ExtensionBlueprint< disabled?: boolean; inputs?: TInputs; output?: TOutput; - configSchema?: PortableSchema; + config?: { + schema: TExtensionConfigSchema & { + [KName in keyof TConfig]?: `Error: Config key '${KName & + string}' is already defined in parent schema`; + }; + }; } & ( | { factory( @@ -955,7 +996,11 @@ export interface ExtensionBlueprint< ) => Expand>, context: { node: AppNode; - config: TConfig; + config: TConfig & { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }; inputs: Expand>; }, ): Expand>; @@ -964,7 +1009,13 @@ export interface ExtensionBlueprint< params: TParams; } ), - ): ExtensionDefinition; + ): ExtensionDefinition< + { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + } & TConfig + >; } // @public (undocumented) @@ -1119,7 +1170,7 @@ export const IconBundleBlueprint: ExtensionBlueprint< {} >; }, - unknown, + {}, { icons: ConfigurableExtensionDataRef< 'core.icons', diff --git a/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts b/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts index bfaf57f522..c69ef5dc60 100644 --- a/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts +++ b/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts @@ -19,7 +19,10 @@ import { z, ZodSchema, ZodTypeDef } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; import { PortableSchema } from './types'; -/** @public */ +/** + * @public + * @deprecated Use the `config.schema` option of `createExtension` instead, or use `createExtensionBlueprint`. + */ export function createSchemaFromZod( schemaCreator: (zImpl: typeof z) => ZodSchema, ): PortableSchema { diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index 4323737540..468da21eb7 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -291,4 +291,66 @@ describe('createExtension', () => { 'ExtensionDefinition{namespace=test,attachTo=root@default}', ); }); + + it('should create an extension with config', () => { + const extension = createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + config: { + schema: { + foo: z => z.string(), + bar: z => z.string().default('bar'), + baz: z => z.string().optional(), + }, + }, + output: { + foo: stringData, + }, + factory({ config }) { + const a1: string = config.foo; + const a2: string = config.bar; + // @ts-expect-error + const a3: string = config.baz; + // @ts-expect-error + const c1: number = config.foo; + // @ts-expect-error + const c2: number = config.bar; + // @ts-expect-error + const c3: number = config.baz; + unused(a1, a2, a3, c1, c2, c3); + + return { + foo: 'bar', + }; + }, + }); + expect(extension.namespace).toBe('test'); + expect(String(extension)).toBe( + 'ExtensionDefinition{namespace=test,attachTo=root@default}', + ); + + expect( + extension.configSchema?.parse({ + foo: 'x', + bar: 'y', + baz: 'z', + qux: 'w', + }), + ).toEqual({ + foo: 'x', + bar: 'y', + baz: 'z', + }); + expect( + extension.configSchema?.parse({ + foo: 'x', + }), + ).toEqual({ + foo: 'x', + bar: 'bar', + }); + expect(() => extension.configSchema?.parse({})).toThrow( + "Missing required value at 'foo'", + ); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index e87f3a638e..ac91557906 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -15,11 +15,11 @@ */ import { AppNode } from '../apis'; -import { PortableSchema } from '../schema'; +import { PortableSchema, createSchemaFromZod } from '../schema'; import { Expand } from '../types'; import { ExtensionDataRef } from './createExtensionDataRef'; import { ExtensionInput } from './createExtensionInput'; - +import { z } from 'zod'; /** @public */ export type AnyExtensionDataMap = { [name in string]: ExtensionDataRef; @@ -82,6 +82,7 @@ export interface CreateExtensionOptions< TOutput extends AnyExtensionDataMap, TInputs extends AnyExtensionInputMap, TConfig, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, > { kind?: string; namespace?: string; @@ -90,10 +91,16 @@ export interface CreateExtensionOptions< disabled?: boolean; inputs?: TInputs; output: TOutput; + /** @deprecated - use `config.schema` instead */ configSchema?: PortableSchema; + config?: { + schema: TConfigSchema; + }; factory(context: { node: AppNode; - config: TConfig; + config: TConfig & { + [key in keyof TConfigSchema]: z.infer>; + }; inputs: Expand>; }): Expand>; } @@ -144,10 +151,25 @@ export function toInternalExtensionDefinition( export function createExtension< TOutput extends AnyExtensionDataMap, TInputs extends AnyExtensionInputMap, - TConfig = never, + TConfig, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, >( - options: CreateExtensionOptions, + options: CreateExtensionOptions, ): ExtensionDefinition { + const newConfigSchema = options.config?.schema; + if (newConfigSchema && options.configSchema) { + throw new Error(`Cannot provide both configSchema and config.schema`); + } + const configSchema = newConfigSchema + ? createSchemaFromZod(innerZ => + innerZ.object( + Object.fromEntries( + Object.entries(newConfigSchema).map(([k, v]) => [k, v(innerZ)]), + ), + ), + ) + : options.configSchema; + return { $$type: '@backstage/ExtensionDefinition', version: 'v1', @@ -158,11 +180,14 @@ export function createExtension< disabled: options.disabled ?? false, inputs: options.inputs ?? {}, output: options.output, - configSchema: options.configSchema, - factory({ inputs, ...rest }) { + 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, }); }, diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index d2373518a3..cb8855d892 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -20,6 +20,8 @@ import { createExtensionBlueprint } from './createExtensionBlueprint'; import { createExtensionTester } from '@backstage/frontend-test-utils'; import { createExtensionDataRef } from './createExtensionDataRef'; +function unused(..._any: any[]) {} + describe('createExtensionBlueprint', () => { it('should allow creation of extension blueprints', () => { const TestExtensionBlueprint = createExtensionBlueprint({ @@ -124,4 +126,152 @@ describe('createExtensionBlueprint', () => { data: dataRef, }); }); + + it('should allow defining a config schema with additional properties in the instance', () => { + const TestExtensionBlueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + config: { + schema: { + text: z => z.string(), + }, + }, + factory(_, { config }) { + // @ts-expect-error + const b = config.something; + + const a: string = config.text; + unused(a); + + expect(config.text).toBe('Hello, world!'); + + return { + element:

{config.text}

, + }; + }, + }); + + const extension = TestExtensionBlueprint.make({ + name: 'my-extension', + params: { + text: 'Hello, world!', + }, + config: { + schema: { + something: z => z.string(), + defaulted: z => z.string().optional().default('default'), + }, + }, + factory(origFactory, { config }) { + const b: string = config.something; + const c: string = config.text; + const d: string = config.defaulted; + + unused(b, c, d); + + expect(config.text).toBe('Hello, world!'); + expect(config.something).toBe('something new!'); + expect(config.defaulted).toBe('lolz'); + return origFactory({}); + }, + }); + + expect.assertions(4); + + createExtensionTester(extension, { + config: { + something: 'something new!', + text: 'Hello, world!', + defaulted: 'lolz', + }, + }).render(); + }); + + it('should not allow overlapping config keys', () => { + const TestExtensionBlueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + config: { + schema: { + text: z => z.string(), + }, + }, + factory(params: { text: string }) { + return { + element:
{params.text}
, + }; + }, + }); + + TestExtensionBlueprint.make({ + name: 'my-extension', + params: { + text: 'Hello, world!', + }, + config: { + schema: { + // @ts-expect-error + text: z => z.number(), + something: z => z.string(), + }, + }, + }); + + expect('test').toBe('test'); + }); + + it('should allow setting config when one was not already defined in the blueprint', () => { + const TestExtensionBlueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + factory(_, { config }) { + // @ts-expect-error + const b = config.something; + + return { + element:
, + }; + }, + }); + + const extension = TestExtensionBlueprint.make({ + name: 'my-extension', + params: { + text: 'Hello, world!', + }, + config: { + schema: { + something: z => z.string(), + defaulted: z => z.string().optional().default('default'), + }, + }, + factory(origFactory, { config }) { + const b: string = config.something; + + unused(b); + + expect(config.something).toBe('something new!'); + expect(config.defaulted).toBe('lolz'); + return origFactory({}); + }, + }); + + expect.assertions(2); + + createExtensionTester(extension, { + config: { + something: 'something new!', + defaulted: 'lolz', + }, + }).render(); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index fb9081f7f8..ce3e650342 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -15,7 +15,6 @@ */ import { AppNode } from '../apis'; -import { PortableSchema } from '../schema'; import { Expand } from '../types'; import { AnyExtensionDataMap, @@ -25,6 +24,7 @@ import { ResolvedExtensionInputs, createExtension, } from './createExtension'; +import { z } from 'zod'; /** * @public @@ -33,7 +33,7 @@ export interface CreateExtensionBlueprintOptions< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, - TConfig, + TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, TDataRefs extends AnyExtensionDataMap, > { kind: string; @@ -42,12 +42,16 @@ export interface CreateExtensionBlueprintOptions< disabled?: boolean; inputs?: TInputs; output: TOutput; - configSchema?: PortableSchema; + config?: { + schema: TConfigSchema; + }; factory( params: TParams, context: { node: AppNode; - config: TConfig; + config: { + [key in keyof TConfigSchema]: z.infer>; + }; inputs: Expand>; }, ): Expand>; @@ -62,7 +66,7 @@ export interface ExtensionBlueprint< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, - TConfig, + TConfig extends { [key in string]: unknown }, TDataRefs extends AnyExtensionDataMap, > { dataRefs: TDataRefs; @@ -73,7 +77,11 @@ export interface ExtensionBlueprint< * You must either pass `params` directly, or define a `factory` that can * optionally call the original factory with the same params. */ - make( + make< + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + >( args: { namespace?: string; name?: string; @@ -81,7 +89,12 @@ export interface ExtensionBlueprint< disabled?: boolean; inputs?: TInputs; output?: TOutput; - configSchema?: PortableSchema; + config?: { + schema: TExtensionConfigSchema & { + [KName in keyof TConfig]?: `Error: Config key '${KName & + string}' is already defined in parent schema`; + }; + }; } & ( | { factory( @@ -94,7 +107,11 @@ export interface ExtensionBlueprint< ) => Expand>, context: { node: AppNode; - config: TConfig; + config: TConfig & { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }; inputs: Expand>; }, ): Expand>; @@ -103,7 +120,13 @@ export interface ExtensionBlueprint< params: TParams; } ), - ): ExtensionDefinition; + ): ExtensionDefinition< + { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + } & TConfig + >; } /** @@ -113,7 +136,7 @@ class ExtensionBlueprintImpl< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, - TConfig, + TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, TDataRefs extends AnyExtensionDataMap, > { constructor( @@ -121,7 +144,7 @@ class ExtensionBlueprintImpl< TParams, TInputs, TOutput, - TConfig, + TConfigSchema, TDataRefs >, ) { @@ -130,30 +153,58 @@ class ExtensionBlueprintImpl< dataRefs: TDataRefs; - public make(args: { + public make< + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + >(args: { namespace?: string; name?: string; attachTo?: { id: string; input: string }; disabled?: boolean; inputs?: TInputs; output?: TOutput; - configSchema?: PortableSchema; params?: TParams; + config?: { + schema: TExtensionConfigSchema; + }; factory?( originalFactory: ( params: TParams, context?: { - config?: TConfig; + config?: { + [key in keyof TConfigSchema]: z.infer< + ReturnType + >; + }; inputs?: Expand>; }, ) => Expand>, context: { node: AppNode; - config: TConfig; + config: { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + } & { + [key in keyof TConfigSchema]: z.infer>; + }; inputs: Expand>; }, ): Expand>; - }): ExtensionDefinition { + }): ExtensionDefinition< + { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + } & { + [key in keyof TConfigSchema]: z.infer>; + } + > { + const schema = { + ...this.options.config?.schema, + ...args.config?.schema, + } as TConfigSchema & TExtensionConfigSchema; return createExtension({ kind: this.options.kind, namespace: args.namespace ?? this.options.namespace, @@ -162,14 +213,18 @@ class ExtensionBlueprintImpl< disabled: args.disabled ?? this.options.disabled, inputs: args.inputs ?? this.options.inputs, output: args.output ?? this.options.output, - configSchema: args.configSchema ?? this.options.configSchema, // TODO: some config merging or smth + config: Object.keys(schema).length === 0 ? undefined : { schema }, factory: ({ node, config, inputs }) => { if (args.factory) { return args.factory( ( innerParams: TParams, innerContext?: { - config?: TConfig; + config?: { + [key in keyof TConfigSchema]: z.infer< + ReturnType + >; + }; inputs?: Expand>; }, ) => @@ -207,16 +262,34 @@ export function createExtensionBlueprint< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, - TConfig, + TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, TDataRefs extends AnyExtensionDataMap = never, >( options: CreateExtensionBlueprintOptions< TParams, TInputs, TOutput, - TConfig, + TConfigSchema, TDataRefs >, -): ExtensionBlueprint { - return new ExtensionBlueprintImpl(options); +): ExtensionBlueprint< + TParams, + TInputs, + TOutput, + string extends keyof TConfigSchema + ? {} + : { [key in keyof TConfigSchema]: z.infer> }, + TDataRefs +> { + return new ExtensionBlueprintImpl(options) as ExtensionBlueprint< + TParams, + TInputs, + TOutput, + string extends keyof TConfigSchema + ? {} + : { + [key in keyof TConfigSchema]: z.infer>; + }, + TDataRefs + >; }