Merge pull request #25761 from backstage/blam/config-merging

NFS: Support config merging in Blueprints
This commit is contained in:
Ben Lambert
2024-07-24 13:58:17 +02:00
committed by GitHub
7 changed files with 418 additions and 49 deletions
+5
View File
@@ -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.
+70 -19
View File
@@ -511,9 +511,12 @@ export function createComponentRef<T extends {} = {}>(options: {
export function createExtension<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
TConfig = never,
TConfig,
TConfigSchema extends {
[key: string]: (zImpl: typeof z) => z.ZodType;
},
>(
options: CreateExtensionOptions<TOutput, TInputs, TConfig>,
options: CreateExtensionOptions<TOutput, TInputs, TConfig, TConfigSchema>,
): ExtensionDefinition<TConfig>;
// @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<TParams, TInputs, TOutput, TConfig, TDataRefs>;
): ExtensionBlueprint<
TParams,
TInputs,
TOutput,
string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
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<TConfig>;
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<ReturnType<TConfigSchema[key]>>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
@@ -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<TConfig>;
// (undocumented)
disabled?: boolean;
// (undocumented)
factory(context: {
node: AppNode;
config: TConfig;
config: TConfig & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
@@ -696,7 +726,7 @@ export function createNavLogoExtension(options: {
namespace?: string;
logoIcon: JSX.Element;
logoFull: JSX.Element;
}): ExtensionDefinition<never>;
}): ExtensionDefinition<unknown>;
// @public (undocumented)
export namespace createNavLogoExtension {
@@ -804,7 +834,7 @@ export namespace createRouterExtension {
>;
}
// @public (undocumented)
// @public @deprecated (undocumented)
export function createSchemaFromZod<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
): PortableSchema<TOutput>;
@@ -851,7 +881,7 @@ export function createSubRouteRef<
// @public (undocumented)
export function createThemeExtension(
theme: AppTheme,
): ExtensionDefinition<never>;
): ExtensionDefinition<unknown>;
// @public (undocumented)
export namespace createThemeExtension {
@@ -867,7 +897,7 @@ export namespace createThemeExtension {
export function createTranslationExtension(options: {
name?: string;
resource: TranslationResource | TranslationMessages;
}): ExtensionDefinition<never>;
}): ExtensionDefinition<unknown>;
// @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<TConfig>;
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<ExtensionDataValues<TOutput>>,
context: {
node: AppNode;
config: TConfig;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
@@ -964,7 +1009,13 @@ export interface ExtensionBlueprint<
params: TParams;
}
),
): ExtensionDefinition<TConfig>;
): ExtensionDefinition<
{
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
} & TConfig
>;
}
// @public (undocumented)
@@ -1119,7 +1170,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<
{}
>;
},
unknown,
{},
{
icons: ConfigurableExtensionDataRef<
'core.icons',
@@ -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<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
): PortableSchema<TOutput> {
@@ -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'",
);
});
});
@@ -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<unknown, string, { optional?: true }>;
@@ -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<TConfig>;
config?: {
schema: TConfigSchema;
};
factory(context: {
node: AppNode;
config: TConfig;
config: TConfig & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Expand<ExtensionDataValues<TOutput>>;
}
@@ -144,10 +151,25 @@ export function toInternalExtensionDefinition<TConfig>(
export function createExtension<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
TConfig = never,
TConfig,
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
>(
options: CreateExtensionOptions<TOutput, TInputs, TConfig>,
options: CreateExtensionOptions<TOutput, TInputs, TConfig, TConfigSchema>,
): ExtensionDefinition<TConfig> {
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<ResolvedExtensionInputs<TInputs>>,
config: config as TConfig & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
...rest,
});
},
@@ -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: <h1>{config.text}</h1>,
};
},
});
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: <div>{params.text}</div>,
};
},
});
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: <div />,
};
},
});
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();
});
});
@@ -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<TConfig>;
config?: {
schema: TConfigSchema;
};
factory(
params: TParams,
context: {
node: AppNode;
config: TConfig;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
@@ -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<TConfig>;
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<ExtensionDataValues<TOutput>>,
context: {
node: AppNode;
config: TConfig;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
@@ -103,7 +120,13 @@ export interface ExtensionBlueprint<
params: TParams;
}
),
): ExtensionDefinition<TConfig>;
): ExtensionDefinition<
{
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
} & 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<TConfig>;
params?: TParams;
config?: {
schema: TExtensionConfigSchema;
};
factory?(
originalFactory: (
params: TParams,
context?: {
config?: TConfig;
config?: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<TConfigSchema[key]>
>;
};
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => Expand<ExtensionDataValues<TOutput>>,
context: {
node: AppNode;
config: TConfig;
config: {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
} & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}): ExtensionDefinition<TConfig> {
}): ExtensionDefinition<
{
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
} & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
}
> {
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<TConfigSchema[key]>
>;
};
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) =>
@@ -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<TParams, TInputs, TOutput, TConfig, TDataRefs> {
return new ExtensionBlueprintImpl(options);
): ExtensionBlueprint<
TParams,
TInputs,
TOutput,
string extends keyof TConfigSchema
? {}
: { [key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>> },
TDataRefs
> {
return new ExtensionBlueprintImpl(options) as ExtensionBlueprint<
TParams,
TInputs,
TOutput,
string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
TDataRefs
>;
}