Merge pull request #33836 from backstage/rugvip/explore-standard-schema-decoupling

frontend-plugin-api: decouple zod dependency using Standard Schema
This commit is contained in:
Patrik Oldsberg
2026-04-14 12:27:53 +02:00
committed by GitHub
49 changed files with 2258 additions and 951 deletions
@@ -48,6 +48,7 @@
"@backstage/filter-predicates": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@standard-schema/spec": "^1.1.0",
"zod": "^3.25.76 || ^4.0.0",
"zod-to-json-schema": "^3.25.1"
},
@@ -14,6 +14,7 @@ import { FilterPredicate } from '@backstage/filter-predicates';
import { JsonObject } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
import { ReactNode } from 'react';
import { StandardSchemaV1 } from '@standard-schema/spec';
import type { z } from 'zod/v3';
// @public
@@ -160,6 +161,91 @@ export interface ExtensionBlueprint<
inputs: T['inputs'];
params: T['params'];
}>;
makeWithOverrides<
TName extends string | undefined,
UFactoryOutput extends ExtensionDataValue<any, any>,
UNewOutput extends ExtensionDataRef,
UParentInputs extends ExtensionDataRef,
TExtraInputs extends {
[inputName in string]: ExtensionInput;
} = {},
TNewExtensionConfigSchema extends {
[key in string]: StandardSchemaV1;
} = {},
>(args: {
name?: TName;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: never;
configSchema?: TNewExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
factory(
originalFactory: <
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
>(
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: T['params'],
context?: {
config?: T['config'];
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
},
) => ExtensionDataContainer<NonNullable<T['output']>>,
context: {
node: AppNode;
apis: ApiHolder;
config: T['config'] & {
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
},
): Iterable<UFactoryOutput> &
VerifyExtensionFactoryOutput<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UFactoryOutput
>;
}): OverridableExtensionDefinition<{
config: Expand<
{
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
} & T['config']
>;
configInput: Expand<
{
[key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput<
TNewExtensionConfigSchema[key]
>;
} & T['configInput']
>;
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: Expand<T['inputs'] & TExtraInputs>;
kind: T['kind'];
name: string | undefined extends TName ? undefined : TName;
params: T['params'];
}>;
// @deprecated (undocumented)
makeWithOverrides<
TName extends string | undefined,
TExtensionConfigSchema extends {
@@ -187,6 +273,7 @@ export interface ExtensionBlueprint<
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
configSchema?: never;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
@@ -212,7 +299,7 @@ export interface ExtensionBlueprint<
apis: ApiHolder;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
@@ -230,7 +317,9 @@ export interface ExtensionBlueprint<
? {}
: {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<
((...args: any[]) => any) & TExtensionConfigSchema[key]
>
>;
}) &
T['config']
@@ -241,7 +330,7 @@ export interface ExtensionBlueprint<
: z.input<
z.ZodObject<{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
((...args: any[]) => any) & TExtensionConfigSchema[key]
>;
}>
>) &
@@ -471,6 +560,104 @@ export interface OverridableExtensionDefinition<
>;
};
// (undocumented)
override<
UFactoryOutput extends ExtensionDataValue<any, any>,
UNewOutput extends ExtensionDataRef,
TExtraInputs extends {
[inputName in string]: ExtensionInput;
},
TParamsInput extends AnyParamsInput_2<NonNullable<T['params']>>,
UParentInputs extends ExtensionDataRef,
TNewExtensionConfigSchema extends {
[key in string]: StandardSchemaV1;
} = {},
>(
args: Expand<
{
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: never;
configSchema?: TNewExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
factory?(
originalFactory: <
TFactoryParamsReturn extends AnyParamsInput_2<
NonNullable<T['params']>
>,
>(
context?: Expand<
{
config?: T['config'];
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
} & ([T['params']] extends [never]
? {}
: {
params?: TFactoryParamsReturn extends ExtensionBlueprintDefineParams
? TFactoryParamsReturn
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: Partial<T['params']>;
})
>,
) => ExtensionDataContainer<NonNullable<T['output']>>,
context: {
node: AppNode;
apis: ApiHolder;
config: T['config'] & {
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
},
): Iterable<UFactoryOutput>;
} & ([T['params']] extends [never]
? {}
: {
params?: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: Partial<T['params']>;
})
> &
VerifyExtensionFactoryOutput<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UFactoryOutput
>,
): OverridableExtensionDefinition<{
kind: T['kind'];
name: T['name'];
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: T['inputs'] & TExtraInputs;
config: T['config'] & {
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
};
configInput: T['configInput'] & {
[key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput<
TNewExtensionConfigSchema[key]
>;
};
}>;
// @deprecated (undocumented)
override<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
@@ -499,6 +686,7 @@ export interface OverridableExtensionDefinition<
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
configSchema?: never;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
@@ -531,7 +719,9 @@ export interface OverridableExtensionDefinition<
apis: ApiHolder;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<
((...args: any[]) => any) & TExtensionConfigSchema[key]
>
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
@@ -560,14 +750,14 @@ export interface OverridableExtensionDefinition<
inputs: T['inputs'] & TExtraInputs;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]>
>;
};
configInput: T['configInput'] &
z.input<
z.ZodObject<{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
((...args: any[]) => any) & TExtensionConfigSchema[key]
>;
}>
>;
@@ -629,9 +819,14 @@ export type PluginWrapperDefinition<TValue = unknown | never> = {
};
// @public (undocumented)
export type PortableSchema<TOutput, TInput = TOutput> = {
export type PortableSchema<TOutput = unknown, TInput = TOutput> = {
parse: (input: TInput) => TOutput;
schema: JsonObject;
schema: {
(): {
schema: JsonObject;
};
[key: string]: any;
};
};
// @public
@@ -644,6 +839,8 @@ export interface RouteRef<
readonly T: TParams;
}
export { StandardSchemaV1 };
// @public
export interface SubRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
+397 -26
View File
@@ -22,6 +22,7 @@ import { JSX as JSX_3 } from 'react/jsx-runtime';
import { Observable } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { ReactNode } from 'react';
import { StandardSchemaV1 } from '@standard-schema/spec';
import { SwappableComponentRef as SwappableComponentRef_2 } from '@backstage/frontend-plugin-api';
import type { z } from 'zod/v3';
@@ -458,6 +459,56 @@ export function createApiRef<T>(): {
};
// @public
export function createExtension<
UOutput extends ExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput;
},
UFactoryOutput extends ExtensionDataValue<any, any>,
const TKind extends string | undefined = undefined,
const TName extends string | undefined = undefined,
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
TNewConfigSchema extends {
[key: string]: StandardSchemaV1;
} = {},
>(
options: CreateExtensionOptions<
TKind,
TName,
UOutput,
TInputs,
{},
UFactoryOutput,
UParentInputs,
TNewConfigSchema
> & {
config?: never;
},
): OverridableExtensionDefinition<{
config: {
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
};
configInput: {
[key in keyof TNewConfigSchema]?: StandardSchemaV1.InferInput<
TNewConfigSchema[key]
>;
};
output: UOutput extends ExtensionDataRef<
infer IData,
infer IId,
infer IConfig
>
? ExtensionDataRef<IData, IId, IConfig>
: never;
inputs: TInputs;
params: never;
kind: string | undefined extends TKind ? undefined : TKind;
name: string | undefined extends TName ? undefined : TName;
}>;
// @public @deprecated (undocumented)
export function createExtension<
UOutput extends ExtensionDataRef,
TInputs extends {
@@ -478,19 +529,26 @@ export function createExtension<
TInputs,
TConfigSchema,
UFactoryOutput,
UParentInputs
>,
UParentInputs,
{}
> & {
configSchema?: never;
},
): OverridableExtensionDefinition<{
config: string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
configInput: string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
[key in keyof TConfigSchema]: ReturnType<
((...args: any[]) => any) & TConfigSchema[key]
>;
}>
>;
output: UOutput extends ExtensionDataRef<
@@ -507,6 +565,77 @@ export function createExtension<
}>;
// @public
export function createExtensionBlueprint<
TParams extends object | ExtensionBlueprintDefineParams,
UOutput extends ExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput;
},
UFactoryOutput extends ExtensionDataValue<any, any>,
TKind extends string,
UParentInputs extends ExtensionDataRef,
TDataRefs extends {
[name in string]: ExtensionDataRef;
} = never,
TNewConfigSchema extends {
[key in string]: StandardSchemaV1;
} = {},
>(
options: {
kind: TKind;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: never;
configSchema?: TNewConfigSchema;
defineParams?: TParams extends ExtensionBlueprintDefineParams
? TParams
: 'The defineParams option must be a function if provided, see the docs for details';
factory(
params: TParams extends ExtensionBlueprintDefineParams
? ReturnType<TParams>['T']
: TParams,
context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Iterable<UFactoryOutput>;
dataRefs?: TDataRefs;
} & VerifyExtensionFactoryOutput<UOutput, UFactoryOutput>,
): ExtensionBlueprint<{
kind: TKind;
params: TParams;
output: UOutput extends ExtensionDataRef<
infer IData,
infer IId,
infer IConfig
>
? ExtensionDataRef<IData, IId, IConfig>
: never;
inputs: string extends keyof TInputs ? {} : TInputs;
config: {
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
};
configInput: {
[key in keyof TNewConfigSchema]?: StandardSchemaV1.InferInput<
TNewConfigSchema[key]
>;
};
dataRefs: TDataRefs;
}>;
// @public @deprecated (undocumented)
export function createExtensionBlueprint<
TParams extends object | ExtensionBlueprintDefineParams,
UOutput extends ExtensionDataRef,
@@ -523,16 +652,38 @@ export function createExtensionBlueprint<
[name in string]: ExtensionDataRef;
} = never,
>(
options: CreateExtensionBlueprintOptions<
TKind,
TParams,
UOutput,
TInputs,
TConfigSchema,
UFactoryOutput,
TDataRefs,
UParentInputs
>,
options: {
kind: TKind;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
configSchema?: never;
config?: {
schema: TConfigSchema;
};
defineParams?: TParams extends ExtensionBlueprintDefineParams
? TParams
: 'The defineParams option must be a function if provided, see the docs for details';
factory(
params: TParams extends ExtensionBlueprintDefineParams
? ReturnType<TParams>['T']
: TParams,
context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Iterable<UFactoryOutput>;
dataRefs?: TDataRefs;
} & VerifyExtensionFactoryOutput<UOutput, UFactoryOutput>,
): ExtensionBlueprint<{
kind: TKind;
params: TParams;
@@ -547,13 +698,17 @@ export function createExtensionBlueprint<
config: string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
configInput: string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
[key in keyof TConfigSchema]: ReturnType<
((...args: any[]) => any) & TConfigSchema[key]
>;
}>
>;
dataRefs: TDataRefs;
@@ -575,6 +730,9 @@ export type CreateExtensionBlueprintOptions<
[name in string]: ExtensionDataRef;
},
UParentInputs extends ExtensionDataRef,
TNewConfigSchema extends {
[key in string]: StandardSchemaV1;
} = {},
> = {
kind: TKind;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
@@ -583,6 +741,7 @@ export type CreateExtensionBlueprintOptions<
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
configSchema?: TNewConfigSchema;
config?: {
schema: TConfigSchema;
};
@@ -597,7 +756,13 @@ export type CreateExtensionBlueprintOptions<
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
} & {
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
@@ -661,6 +826,9 @@ export type CreateExtensionOptions<
},
UFactoryOutput extends ExtensionDataValue<any, any>,
UParentInputs extends ExtensionDataRef,
TNewConfigSchema extends {
[key: string]: StandardSchemaV1;
} = {},
> = {
kind?: TKind;
name?: TName;
@@ -670,6 +838,7 @@ export type CreateExtensionOptions<
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
configSchema?: TNewConfigSchema;
config?: {
schema: TConfigSchema;
};
@@ -677,7 +846,13 @@ export type CreateExtensionOptions<
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
} & {
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Iterable<UFactoryOutput>;
@@ -1046,6 +1221,91 @@ export interface ExtensionBlueprint<
inputs: T['inputs'];
params: T['params'];
}>;
makeWithOverrides<
TName extends string | undefined,
UFactoryOutput extends ExtensionDataValue<any, any>,
UNewOutput extends ExtensionDataRef,
UParentInputs extends ExtensionDataRef,
TExtraInputs extends {
[inputName in string]: ExtensionInput;
} = {},
TNewExtensionConfigSchema extends {
[key in string]: StandardSchemaV1;
} = {},
>(args: {
name?: TName;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: never;
configSchema?: TNewExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
factory(
originalFactory: <
TParamsInput extends AnyParamsInput_2<NonNullable<T['params']>>,
>(
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: T['params'],
context?: {
config?: T['config'];
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
},
) => ExtensionDataContainer<NonNullable<T['output']>>,
context: {
node: AppNode;
apis: ApiHolder;
config: T['config'] & {
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
},
): Iterable<UFactoryOutput> &
VerifyExtensionFactoryOutput<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UFactoryOutput
>;
}): OverridableExtensionDefinition<{
config: Expand<
{
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
} & T['config']
>;
configInput: Expand<
{
[key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput<
TNewExtensionConfigSchema[key]
>;
} & T['configInput']
>;
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: Expand<T['inputs'] & TExtraInputs>;
kind: T['kind'];
name: string | undefined extends TName ? undefined : TName;
params: T['params'];
}>;
// @deprecated (undocumented)
makeWithOverrides<
TName extends string | undefined,
TExtensionConfigSchema extends {
@@ -1073,6 +1333,7 @@ export interface ExtensionBlueprint<
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
configSchema?: never;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
@@ -1098,7 +1359,7 @@ export interface ExtensionBlueprint<
apis: ApiHolder;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
@@ -1116,7 +1377,9 @@ export interface ExtensionBlueprint<
? {}
: {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<
((...args: any[]) => any) & TExtensionConfigSchema[key]
>
>;
}) &
T['config']
@@ -1127,7 +1390,7 @@ export interface ExtensionBlueprint<
: z.input<
z.ZodObject<{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
((...args: any[]) => any) & TExtensionConfigSchema[key]
>;
}>
>) &
@@ -1687,6 +1950,104 @@ export interface OverridableExtensionDefinition<
>;
};
// (undocumented)
override<
UFactoryOutput extends ExtensionDataValue<any, any>,
UNewOutput extends ExtensionDataRef,
TExtraInputs extends {
[inputName in string]: ExtensionInput;
},
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
UParentInputs extends ExtensionDataRef,
TNewExtensionConfigSchema extends {
[key in string]: StandardSchemaV1;
} = {},
>(
args: Expand<
{
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: never;
configSchema?: TNewExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
factory?(
originalFactory: <
TFactoryParamsReturn extends AnyParamsInput<
NonNullable<T['params']>
>,
>(
context?: Expand<
{
config?: T['config'];
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
} & ([T['params']] extends [never]
? {}
: {
params?: TFactoryParamsReturn extends ExtensionBlueprintDefineParams
? TFactoryParamsReturn
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: Partial<T['params']>;
})
>,
) => ExtensionDataContainer<NonNullable<T['output']>>,
context: {
node: AppNode;
apis: ApiHolder;
config: T['config'] & {
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
},
): Iterable<UFactoryOutput>;
} & ([T['params']] extends [never]
? {}
: {
params?: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: Partial<T['params']>;
})
> &
VerifyExtensionFactoryOutput<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UFactoryOutput
>,
): OverridableExtensionDefinition<{
kind: T['kind'];
name: T['name'];
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: T['inputs'] & TExtraInputs;
config: T['config'] & {
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
};
configInput: T['configInput'] & {
[key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput<
TNewExtensionConfigSchema[key]
>;
};
}>;
// @deprecated (undocumented)
override<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
@@ -1715,6 +2076,7 @@ export interface OverridableExtensionDefinition<
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
configSchema?: never;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
@@ -1747,7 +2109,9 @@ export interface OverridableExtensionDefinition<
apis: ApiHolder;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<
((...args: any[]) => any) & TExtensionConfigSchema[key]
>
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
@@ -1776,14 +2140,14 @@ export interface OverridableExtensionDefinition<
inputs: T['inputs'] & TExtraInputs;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]>
>;
};
configInput: T['configInput'] &
z.input<
z.ZodObject<{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
((...args: any[]) => any) & TExtensionConfigSchema[key]
>;
}>
>;
@@ -2039,9 +2403,14 @@ export type PluginWrapperDefinition<TValue = unknown | never> = {
};
// @public (undocumented)
export type PortableSchema<TOutput, TInput = TOutput> = {
export type PortableSchema<TOutput = unknown, TInput = TOutput> = {
parse: (input: TInput) => TOutput;
schema: JsonObject;
schema: {
(): {
schema: JsonObject;
};
[key: string]: any;
};
};
// @public
@@ -2126,6 +2495,8 @@ export namespace SessionState {
export type SignedOut = typeof SessionState.SignedOut;
}
export { StandardSchemaV1 };
// @public
export interface StorageApi {
forBucket(name: string): StorageApi;
+1 -1
View File
@@ -47,7 +47,7 @@ export type {
AppNodeSpec,
AppTree,
} from './apis';
export type { PortableSchema } from './schema';
export type { PortableSchema, StandardSchemaV1 } from './schema';
export type {
AnyRouteRefParams,
RouteRef,
@@ -182,18 +182,14 @@ describe('ApiBlueprint', () => {
"input": "apis",
},
"configSchema": {
"parse": [Function],
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"test": {
"default": "test",
"type": "string",
},
"_fields": {
"test": {
"required": false,
"toJsonSchema": [Function],
"validate": [Function],
},
"type": "object",
},
"parse": [Function],
},
"disabled": false,
"factory": [Function],
@@ -39,17 +39,14 @@ describe('NavItemBlueprint', () => {
"input": "items",
},
"configSchema": {
"parse": [Function],
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
},
"_fields": {
"title": {
"required": false,
"toJsonSchema": [Function],
"validate": [Function],
},
"type": "object",
},
"parse": [Function],
},
"disabled": false,
"factory": [Function],
@@ -48,20 +48,19 @@ describe('PageBlueprint', () => {
"input": "routes",
},
"configSchema": {
"parse": [Function],
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
},
"title": {
"type": "string",
},
"_fields": {
"path": {
"required": false,
"toJsonSchema": [Function],
"validate": [Function],
},
"title": {
"required": false,
"toJsonSchema": [Function],
"validate": [Function],
},
"type": "object",
},
"parse": [Function],
},
"disabled": false,
"factory": [Function],
@@ -0,0 +1,381 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z as zodV3 } from 'zod/v3';
import { z as zodV4 } from 'zod/v4';
import {
createConfigSchema,
createDeprecatedConfigSchema,
mergePortableSchemas,
} from './createPortableSchema';
describe('createConfigSchema', () => {
describe('zod v4 schemas', () => {
it('should report a missing required field', () => {
const schema = createConfigSchema({ name: zodV4.string() });
expect(() => schema.parse({})).toThrow(
"Invalid input: expected string, received undefined at 'name'",
);
});
it('should report a type mismatch', () => {
const schema = createConfigSchema({ count: zodV4.number() });
expect(() => schema.parse({ count: 'not a number' })).toThrow(
"Invalid input: expected number, received string at 'count'",
);
});
it('should report nested object errors with the full path', () => {
const schema = createConfigSchema({
settings: zodV4.object({ port: zodV4.number() }),
});
expect(() => schema.parse({ settings: { port: 'abc' } })).toThrow(
"Invalid input: expected number, received string at 'settings.port'",
);
});
it('should combine errors from multiple fields', () => {
const schema = createConfigSchema({
name: zodV4.string(),
count: zodV4.number(),
});
expect(() => schema.parse({})).toThrow(
"Invalid input: expected string, received undefined at 'name'; " +
"Invalid input: expected number, received undefined at 'count'",
);
});
it('should apply defaults for optional fields with defaults', () => {
const schema = createConfigSchema({
name: zodV4.string(),
mode: zodV4.enum(['fast', 'slow']).default('fast'),
});
expect(schema.parse({ name: 'test' })).toEqual({
name: 'test',
mode: 'fast',
});
});
it('should parse valid config', () => {
const schema = createConfigSchema({
name: zodV4.string(),
count: zodV4.number().optional(),
});
expect(schema.parse({ name: 'hello' })).toEqual({ name: 'hello' });
expect(schema.parse({ name: 'hello', count: 5 })).toEqual({
name: 'hello',
count: 5,
});
});
});
describe('zod v3 rejection', () => {
it('should reject a direct zod v3 schema', () => {
expect(() => createConfigSchema({ name: zodV3.string() as any })).toThrow(
"Config schema for field 'name' uses a Zod v3 schema, which is " +
'not supported by the `configSchema` option. Either use ' +
"`import { z } from 'zod/v4'` from the zod v3 package, or " +
'upgrade to zod v4.',
);
});
});
describe('schema creation errors', () => {
it('should reject a schema that is not a valid Standard Schema', () => {
expect(() =>
createConfigSchema({ bad: { notASchema: true } as any }),
).toThrow("Config schema for field 'bad' is not a valid Standard Schema");
});
it('should reject a Standard Schema without JSON Schema support', () => {
const fakeStandardSchema = {
'~standard': {
version: 1,
vendor: 'fake',
validate: () => ({ value: 'ok' }),
},
};
expect(() =>
createConfigSchema({ field: fakeStandardSchema as any }),
).toThrow(
"Config schema for field 'field' does not support JSON Schema conversion",
);
});
});
describe('JSON Schema generation', () => {
it('should generate JSON Schema lazily via schema()', () => {
const schema = createConfigSchema({
title: zodV4.string(),
count: zodV4.number().optional(),
});
const result = schema.schema();
expect(result).toHaveProperty('schema');
expect(result.schema).toMatchObject({
type: 'object',
properties: {
title: { type: 'string' },
count: { type: 'number' },
},
required: ['title'],
additionalProperties: false,
});
});
it('should support backward-compatible property access on schema', () => {
const schema = createConfigSchema({
title: zodV4.string(),
});
expect(schema.schema.type).toBe('object');
expect(schema.schema.properties).toBeDefined();
});
});
describe('merging schemas', () => {
it('should merge two zod v4 schemas and parse correctly', () => {
const a = createConfigSchema({ name: zodV4.string() });
const b = createConfigSchema({ count: zodV4.number().default(0) });
const merged = mergePortableSchemas(a, b)!;
expect(merged.parse({ name: 'hello' })).toEqual({
name: 'hello',
count: 0,
});
});
it('should merge deprecated v3 and new v4 schemas', () => {
const a = createDeprecatedConfigSchema({ name: z => z.string() });
const b = createConfigSchema({ count: zodV4.number().default(0) });
const merged = mergePortableSchemas(a, b)!;
expect(merged.parse({ name: 'hello' })).toEqual({
name: 'hello',
count: 0,
});
});
it('should produce combined errors after merge', () => {
const a = createDeprecatedConfigSchema({ name: z => z.string() });
const b = createConfigSchema({ count: zodV4.number() });
const merged = mergePortableSchemas(a, b)!;
expect(() => merged.parse({})).toThrow(
"Missing required value at 'name'; " +
"Invalid input: expected number, received undefined at 'count'",
);
});
it('should produce combined errors for type mismatches after merge', () => {
const a = createDeprecatedConfigSchema({ name: z => z.string() });
const b = createConfigSchema({ count: zodV4.number() });
const merged = mergePortableSchemas(a, b)!;
expect(() => merged.parse({ name: 123, count: 'not a number' })).toThrow(
"Expected string, received number at 'name'; " +
"Invalid input: expected number, received string at 'count'",
);
});
it('should produce correct JSON Schema after merge', () => {
const a = createDeprecatedConfigSchema({ name: z => z.string() });
const b = createConfigSchema({ count: zodV4.number().optional() });
const merged = mergePortableSchemas(a, b)!;
const result = merged.schema();
expect(result.schema).toMatchObject({
type: 'object',
properties: {
name: { type: 'string' },
count: { type: 'number' },
},
required: ['name'],
additionalProperties: false,
});
});
it('should handle merge with undefined', () => {
const a = createConfigSchema({ name: zodV4.string() });
expect(mergePortableSchemas(a, undefined)).toBe(a);
expect(mergePortableSchemas(undefined, a)).toBe(a);
expect(mergePortableSchemas(undefined, undefined)).toBeUndefined();
});
it('should let later fields win when merging overlapping keys', () => {
const a = createDeprecatedConfigSchema({ x: z => z.string() });
const b = createConfigSchema({ x: zodV4.number() });
const merged = mergePortableSchemas(a, b)!;
expect(merged.parse({ x: 42 })).toEqual({ x: 42 });
expect(() => merged.parse({ x: 'hello' })).toThrow(
"Invalid input: expected number, received string at 'x'",
);
});
});
describe('edge cases', () => {
it('should handle an empty configSchema', () => {
const schema = createConfigSchema({});
expect(schema.parse({})).toEqual({});
expect(schema.parse(undefined)).toEqual({});
const result = schema.schema();
expect(result.schema).toEqual({
type: 'object',
properties: {},
additionalProperties: false,
});
});
it('should throw a clear error for non-object input', () => {
const schema = createConfigSchema({ title: zodV4.string() });
expect(() => schema.parse('not an object')).toThrow(
'Invalid config input, expected object but got string',
);
expect(() => schema.parse(42)).toThrow(
'Invalid config input, expected object but got number',
);
expect(() => schema.parse([1, 2])).toThrow(
'Invalid config input, expected object but got array',
);
expect(() => schema.parse(true)).toThrow(
'Invalid config input, expected object but got boolean',
);
});
it('should not produce undefined keys for absent optional fields', () => {
const schema = createConfigSchema({
name: zodV4.string(),
title: zodV4.string().optional(),
count: zodV4.number().default(42),
});
const result = schema.parse({ name: 'hello' });
expect(result).toEqual({ name: 'hello', count: 42 });
expect(Object.keys(result as object)).toEqual(['name', 'count']);
});
it('should not mark defaulted fields as required in JSON Schema', () => {
const schema = createConfigSchema({
name: zodV4.string(),
title: zodV4.string().default('hello'),
count: zodV4.number().optional(),
});
const result = schema.schema();
expect(result.schema).toMatchObject({
type: 'object',
required: ['name'],
});
});
});
});
describe('createDeprecatedConfigSchema', () => {
it('should report a missing required field', () => {
const schema = createDeprecatedConfigSchema({ name: z => z.string() });
expect(() => schema.parse({})).toThrow("Missing required value at 'name'");
expect(() => schema.parse(undefined)).toThrow(
"Missing required value at 'name'",
);
});
it('should report a type mismatch', () => {
const schema = createDeprecatedConfigSchema({ count: z => z.number() });
expect(() => schema.parse({ count: 'not a number' })).toThrow(
"Expected number, received string at 'count'",
);
});
it('should report nested object errors with the full path', () => {
const schema = createDeprecatedConfigSchema({
settings: z => z.object({ port: z.number() }),
});
expect(() => schema.parse({ settings: { port: 'abc' } })).toThrow(
"Expected number, received string at 'settings.port'",
);
});
it('should report errors for union types', () => {
const schema = createDeprecatedConfigSchema({
value: z => z.union([z.string(), z.number()]),
});
expect(() => schema.parse({})).toThrow("Missing required value at 'value'");
});
it('should apply defaults for optional fields with defaults', () => {
const schema = createDeprecatedConfigSchema({
name: z => z.string(),
mode: z => z.enum(['fast', 'slow']).default('fast'),
});
expect(schema.parse({ name: 'test' })).toEqual({
name: 'test',
mode: 'fast',
});
});
it('should generate JSON Schema lazily via schema()', () => {
const schema = createDeprecatedConfigSchema({
title: z => z.string(),
count: z => z.number().optional(),
});
const result = schema.schema();
expect(result).toHaveProperty('schema');
expect(result.schema).toMatchObject({
type: 'object',
properties: {
title: { type: 'string' },
count: { type: 'number' },
},
required: ['title'],
additionalProperties: false,
});
});
it('should not mark defaulted fields as required in JSON Schema', () => {
const schema = createDeprecatedConfigSchema({
name: z => z.string(),
title: z => z.string().default('hello'),
count: z => z.number().optional(),
});
const result = schema.schema();
expect(result.schema).toMatchObject({
type: 'object',
required: ['name'],
});
});
});
@@ -0,0 +1,364 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonObject } from '@backstage/types';
import { z as zodV3, type ZodType } from 'zod/v3';
import zodToJsonSchema from 'zod-to-json-schema';
import { PortableSchema } from './types';
/**
* The Standard Schema interface.
* @public
*/
export type { StandardSchemaV1 } from '@standard-schema/spec';
import { type StandardSchemaV1 } from '@standard-schema/spec';
/** @internal */
export function createDeprecatedConfigSchema(
fields: Record<string, (zImpl: typeof zodV3) => ZodType>,
): MergeablePortableSchema {
const resolved: Record<string, ResolvedField> = {};
for (const [key, field] of Object.entries(fields)) {
resolved[key] = resolveZodField(key, field(zodV3));
}
return buildPortableSchema(resolved);
}
/**
* Per-field resolved schema validation is eager, JSON Schema is lazy.
* @internal
*/
interface ResolvedField {
validate(value: unknown): { value: unknown } | { errors: string[] };
toJsonSchema(): JsonObject;
required: boolean;
}
/**
* Internal representation that carries per-field resolvers alongside the
* public PortableSchema surface, enabling schema merging.
* @internal
*/
export interface MergeablePortableSchema<TOutput = any, TInput = any>
extends PortableSchema<TOutput, TInput> {
/** @internal */
readonly _fields: Record<string, ResolvedField>;
}
/**
* Resolves each field, eagerly validates JSON Schema support, and returns
* a PortableSchema whose JSON Schema conversion is lazy.
* @internal
*/
export function createConfigSchema(
fields: Record<string, StandardSchemaV1>,
): MergeablePortableSchema {
const resolved: Record<string, ResolvedField> = {};
for (const [key, field] of Object.entries(fields)) {
resolved[key] = resolveField(key, field);
}
return buildPortableSchema(resolved);
}
/**
* Combines schemas from different sources for blueprint + override
* composition. Each source may use a completely different schema library.
* Because we track per-field resolvers, merging is just combining the
* field maps.
* @internal
*/
export function mergePortableSchemas<A, B>(
a: MergeablePortableSchema<A> | undefined,
b: MergeablePortableSchema<B> | undefined,
): MergeablePortableSchema<A & B> | undefined {
if (!a && !b) {
return undefined;
}
if (!a) {
return b as MergeablePortableSchema<A & B>;
}
if (!b) {
return a as MergeablePortableSchema<A & B>;
}
return buildPortableSchema<A & B>({
...a._fields,
...b._fields,
});
}
/**
* Assembles resolved fields into a PortableSchema with per-field
* validation (eager) and lazy JSON Schema generation.
*/
function buildPortableSchema<TOutput = unknown>(
fields: Record<string, ResolvedField>,
): MergeablePortableSchema<TOutput> {
function parse(input: unknown) {
if (
input !== undefined &&
input !== null &&
(typeof input !== 'object' || Array.isArray(input))
) {
throw new Error(
`Invalid config input, expected object but got ${
Array.isArray(input) ? 'array' : typeof input
}`,
);
}
const inputObj = (input ?? {}) as Record<string, unknown>;
const result: Record<string, unknown> = {};
const errors: string[] = [];
for (const [key, field] of Object.entries(fields)) {
const validated = field.validate(inputObj[key]);
if ('errors' in validated) {
errors.push(...validated.errors);
} else if (validated.value !== undefined || key in inputObj) {
result[key] = validated.value;
}
}
if (errors.length > 0) {
throw new Error(errors.join('; '));
}
return result as TOutput;
}
const result: MergeablePortableSchema<TOutput> = {
parse,
schema: undefined as any,
_fields: fields,
};
// Lazy getter — computes JSON Schema on first access, then caches it.
let cached: PortableSchema['schema'] | undefined;
Object.defineProperty(result, 'schema', {
get() {
if (!cached) {
const jsonSchema = buildObjectJsonSchema(fields);
const callable = Object.assign(
() => ({ schema: jsonSchema }),
jsonSchema,
);
cached = callable as PortableSchema['schema'];
}
return cached;
},
configurable: true,
enumerable: false,
});
return result;
}
/**
* Wraps a single schema into a ResolvedField. Eagerly validates that
* JSON Schema conversion will be possible, but defers the actual
* conversion until toJsonSchema() is called.
*/
function resolveField(key: string, schema: unknown): ResolvedField {
if (isZodV3Type(schema)) {
throw new Error(
`Config schema for field '${key}' uses a Zod v3 schema, which is ` +
`not supported by the \`configSchema\` option. Either use ` +
`\`import { z } from 'zod/v4'\` from the zod v3 package, or ` +
`upgrade to zod v4.`,
);
}
if (isStandardSchema(schema)) {
if (!hasJsonSchemaConverter(schema)) {
throw new Error(
`Config schema for field '${key}' does not support JSON Schema ` +
`conversion. Use a schema library that implements the Standard ` +
`JSON Schema interface (like zod v4+).`,
);
}
return resolveStandardField(key, schema);
}
throw new Error(
`Config schema for field '${key}' is not a valid Standard Schema`,
);
}
function resolveZodField(key: string, schema: ZodType): ResolvedField {
const wrapper = zodV3.object({ [key]: schema });
return {
validate(value) {
const result = wrapper.safeParse({ [key]: value });
if (result.success) {
return { value: result.data[key] };
}
return { errors: result.error.issues.map(formatZodIssue) };
},
toJsonSchema() {
const wholeJsonSchema = zodToJsonSchema(wrapper) as Record<string, any>;
return (wholeJsonSchema.properties?.[key] ?? {}) as JsonObject;
},
required: !schema.isOptional(),
};
}
function resolveStandardField(
key: string,
schema: StandardSchemaV1 & {
'~standard': { jsonSchema: { input: Function } };
},
): ResolvedField {
const required = isFieldRequired(schema);
return {
validate(value) {
const result = schema['~standard'].validate(value);
if (result instanceof Promise) {
throw new Error(
`Config schema for '${key}' returned a Promise — async schemas are not supported`,
);
}
if (result.issues) {
return {
errors: Array.from(result.issues).map(issue =>
formatStandardIssue(key, issue),
),
};
}
return { value: result.value };
},
toJsonSchema() {
const raw = schema['~standard'].jsonSchema.input({ target: 'draft-07' });
const { $schema: _, ...rest } = raw;
return rest as JsonObject;
},
required,
};
}
/** Assembles per-field JSON Schemas into a single object-level JSON Schema. */
function buildObjectJsonSchema(
fields: Record<string, ResolvedField>,
): JsonObject {
const properties: Record<string, JsonObject> = {};
const required: string[] = [];
for (const [key, field] of Object.entries(fields)) {
properties[key] = field.toJsonSchema();
if (field.required) {
required.push(key);
}
}
const schema: Record<string, unknown> = {
type: 'object',
properties,
additionalProperties: false,
};
if (required.length > 0) {
schema.required = required;
}
return schema as JsonObject;
}
function isZodV3Type(value: unknown): value is ZodType {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as any)._parse === 'function' &&
'_def' in value
);
}
function isStandardSchema(value: unknown): value is StandardSchemaV1 {
return (
typeof value === 'object' &&
value !== null &&
'~standard' in value &&
typeof (value as any)['~standard']?.validate === 'function'
);
}
function hasJsonSchemaConverter(
schema: StandardSchemaV1,
): schema is StandardSchemaV1 & {
'~standard': { jsonSchema: { input: Function } };
} {
const std = schema['~standard'] as any;
return typeof std?.jsonSchema?.input === 'function';
}
function isFieldRequired(schema: StandardSchemaV1): boolean {
const result = schema['~standard'].validate(undefined);
if (result instanceof Promise) {
return true;
}
return (result.issues?.length ?? 0) > 0;
}
function formatZodIssue(issue: {
code: string;
message: string;
path: Array<string | number>;
unionErrors?: Array<{ issues: Array<any> }>;
}): string {
if (issue.code === 'invalid_union' && issue.unionErrors?.[0]?.issues?.[0]) {
return formatZodIssue(issue.unionErrors[0].issues[0]);
}
let message = issue.message;
if (message === 'Required') {
message = 'Missing required value';
}
if (issue.path.length) {
message += ` at '${issue.path.join('.')}'`;
}
return message;
}
function formatStandardIssue(
fieldKey: string,
issue: StandardSchemaV1.Issue,
): string {
let message = issue.message;
if (message === 'Required') {
message = 'Missing required value';
}
const path = issue.path?.length
? `${fieldKey}.${issue.path
.map((p: PropertyKey | StandardSchemaV1.PathSegment) =>
typeof p === 'object' ? p.key : p,
)
.join('.')}`
: fieldKey;
return `${message} at '${path}'`;
}
/** @internal */
export function warnConfigSchemaPropDeprecation(callSite: string) {
// eslint-disable-next-line no-console
console.warn(
`DEPRECATION WARNING: The \`config.schema\` option for extension config is deprecated. ` +
`Use the \`configSchema\` option instead with Standard Schema values, for example ` +
`\`configSchema: { title: z.string() }\` using zod v4 ` +
`(or \`import { z } from 'zod/v4'\` from the zod v3 package). ` +
`Declared at ${callSite}`,
);
}
@@ -1,43 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createSchemaFromZod } from './createSchemaFromZod';
describe('createSchemaFromZod', () => {
it('should provide nice parse errors', () => {
const { parse } = createSchemaFromZod(z =>
z.object({
foo: z.union([z.string(), z.number()]),
derp: z.object({ bar: z.number() }),
}),
);
expect(() => {
// @ts-expect-error
return parse({ derp: { bar: 'derp' } });
}).toThrow(
`Missing required value at 'foo'; Expected number, received string at 'derp.bar'`,
);
expect(() => {
// @ts-expect-error
return parse(undefined);
}).toThrow(`Missing required value`);
expect(() => {
// @ts-expect-error
return parse('derp');
}).toThrow(`Expected object, received string`);
});
});
@@ -1,56 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonObject } from '@backstage/types';
import { z, type ZodIssue, type ZodType } from 'zod/v3';
import zodToJsonSchema from 'zod-to-json-schema';
import { PortableSchema } from './types';
/**
* @internal
*/
export function createSchemaFromZod<TSchema extends ZodType>(
schemaCreator: (zImpl: typeof z) => TSchema,
): PortableSchema<z.output<TSchema>, z.input<TSchema>> {
const schema = schemaCreator(z);
return {
// TODO: Types allow z.array etc here but it will break stuff
parse: input => {
const result = schema.safeParse(input);
if (result.success) {
return result.data;
}
throw new Error(result.error.issues.map(formatIssue).join('; '));
},
// TODO: Verify why we are not compatible with the latest zodToJsonSchema.
schema: zodToJsonSchema(schema) as JsonObject,
};
}
function formatIssue(issue: ZodIssue): string {
if (issue.code === 'invalid_union') {
return formatIssue(issue.unionErrors[0].issues[0]);
}
let message = issue.message;
if (message === 'Required') {
message = `Missing required value`;
}
if (issue.path.length) {
message += ` at '${issue.path.join('.')}'`;
}
return message;
}
@@ -15,3 +15,4 @@
*/
export { type PortableSchema } from './types';
export { type StandardSchemaV1 } from './createPortableSchema';
@@ -17,7 +17,16 @@
import { JsonObject } from '@backstage/types';
/** @public */
export type PortableSchema<TOutput, TInput = TOutput> = {
export type PortableSchema<TOutput = unknown, TInput = TOutput> = {
parse: (input: TInput) => TOutput;
schema: JsonObject;
/**
* The JSON Schema for this portable schema.
*
* @remarks
* Can be accessed as a property for backward compatibility (returns the
* JSON Schema object directly), or called as a method which returns
* `{ schema: JsonObject }`. Both forms compute the schema lazily on
* first access. The property form is deprecated prefer `schema()`.
*/
schema: { (): { schema: JsonObject }; [key: string]: any };
};
@@ -27,7 +27,14 @@ import {
import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef';
import { ExtensionInput } from './createExtensionInput';
import type { z } from 'zod/v3';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import {
createConfigSchema,
createDeprecatedConfigSchema,
mergePortableSchemas,
warnConfigSchemaPropDeprecation,
} from '../schema/createPortableSchema';
import { describeParentCallSite } from '../routing/describeParentCallSite';
import { type StandardSchemaV1 } from '@standard-schema/spec';
import { OpaqueExtensionDefinition } from '@internal/frontend';
import { ExtensionDataContainer } from './types';
import {
@@ -169,6 +176,7 @@ export type CreateExtensionOptions<
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
UParentInputs extends ExtensionDataRef,
TNewConfigSchema extends { [key: string]: StandardSchemaV1 } = {},
> = {
kind?: TKind;
name?: TName;
@@ -178,14 +186,27 @@ export type CreateExtensionOptions<
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
configSchema?: TNewConfigSchema;
/**
* @deprecated Use {@link CreateExtensionOptions.configSchema} instead.
*/
config?: {
/**
* @deprecated Use {@link CreateExtensionOptions.configSchema} instead.
*/
schema: TConfigSchema;
};
factory(context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
} & {
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Iterable<UFactoryOutput>;
@@ -237,6 +258,105 @@ export interface OverridableExtensionDefinition<
>;
};
override<
UFactoryOutput extends ExtensionDataValue<any, any>,
UNewOutput extends ExtensionDataRef,
TExtraInputs extends { [inputName in string]: ExtensionInput },
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
UParentInputs extends ExtensionDataRef,
TNewExtensionConfigSchema extends {
[key in string]: StandardSchemaV1;
} = {},
>(
args: Expand<
{
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: never;
configSchema?: TNewExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
factory?(
originalFactory: <
TFactoryParamsReturn extends AnyParamsInput<
NonNullable<T['params']>
>,
>(
context?: Expand<
{
config?: T['config'];
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
} & ([T['params']] extends [never]
? {}
: {
params?: TFactoryParamsReturn extends ExtensionBlueprintDefineParams
? TFactoryParamsReturn
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: Partial<T['params']>;
})
>,
) => ExtensionDataContainer<NonNullable<T['output']>>,
context: {
node: AppNode;
apis: ApiHolder;
config: T['config'] & {
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
},
): Iterable<UFactoryOutput>;
} & ([T['params']] extends [never]
? {}
: {
params?: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: Partial<T['params']>;
})
> &
VerifyExtensionFactoryOutput<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UFactoryOutput
>,
): OverridableExtensionDefinition<{
kind: T['kind'];
name: T['name'];
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: T['inputs'] & TExtraInputs;
config: T['config'] & {
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
};
configInput: T['configInput'] & {
[key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput<
TNewExtensionConfigSchema[key]
>;
};
}>;
/**
* @deprecated Use the `configSchema` option instead of `config.schema`.
*/
override<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
@@ -263,6 +383,7 @@ export interface OverridableExtensionDefinition<
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
configSchema?: never;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
@@ -295,7 +416,9 @@ export interface OverridableExtensionDefinition<
apis: ApiHolder;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<
((...args: any[]) => any) & TExtensionConfigSchema[key]
>
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
@@ -324,14 +447,14 @@ export interface OverridableExtensionDefinition<
inputs: T['inputs'] & TExtraInputs;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]>
>;
};
configInput: T['configInput'] &
z.input<
z.ZodObject<{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
((...args: any[]) => any) & TExtensionConfigSchema[key]
>;
}>
>;
@@ -397,6 +520,53 @@ function bindInputs(
*
* @public
*/
export function createExtension<
UOutput extends ExtensionDataRef,
TInputs extends { [inputName in string]: ExtensionInput },
UFactoryOutput extends ExtensionDataValue<any, any>,
const TKind extends string | undefined = undefined,
const TName extends string | undefined = undefined,
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
TNewConfigSchema extends { [key: string]: StandardSchemaV1 } = {},
>(
options: CreateExtensionOptions<
TKind,
TName,
UOutput,
TInputs,
{},
UFactoryOutput,
UParentInputs,
TNewConfigSchema
> & { config?: never },
): OverridableExtensionDefinition<{
config: {
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
};
configInput: {
[key in keyof TNewConfigSchema]?: StandardSchemaV1.InferInput<
TNewConfigSchema[key]
>;
};
output: UOutput extends ExtensionDataRef<
infer IData,
infer IId,
infer IConfig
>
? ExtensionDataRef<IData, IId, IConfig>
: never;
inputs: TInputs;
params: never;
kind: string | undefined extends TKind ? undefined : TKind;
name: string | undefined extends TName ? undefined : TName;
}>;
/**
* @deprecated Use the top-level `configSchema` option instead of `config.schema`.
* @public
*/
export function createExtension<
UOutput extends ExtensionDataRef,
TInputs extends { [inputName in string]: ExtensionInput },
@@ -413,22 +583,26 @@ export function createExtension<
TInputs,
TConfigSchema,
UFactoryOutput,
UParentInputs
>,
UParentInputs,
{}
> & { configSchema?: never },
): OverridableExtensionDefinition<{
config: string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
configInput: string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
[key in keyof TConfigSchema]: ReturnType<
((...args: any[]) => any) & TConfigSchema[key]
>;
}>
>;
// This inference and remapping back to ExtensionDataRef eliminates any occurrences ConfigurationExtensionDataRef
output: UOutput extends ExtensionDataRef<
infer IData,
infer IId,
@@ -440,39 +614,24 @@ export function createExtension<
params: never;
kind: string | undefined extends TKind ? undefined : TKind;
name: string | undefined extends TName ? undefined : TName;
}> {
const schemaDeclaration = options.config?.schema;
const configSchema =
schemaDeclaration &&
createSchemaFromZod(innerZ =>
innerZ.object(
Object.fromEntries(
Object.entries(schemaDeclaration).map(([k, v]) => [k, v(innerZ)]),
),
),
);
}>;
/** @internal */
export function createExtension(
options: any,
): OverridableExtensionDefinition<any> {
if (options.config?.schema) {
warnConfigSchemaPropDeprecation(describeParentCallSite());
}
const resolvedConfigSchema = mergePortableSchemas(
options.config?.schema
? createDeprecatedConfigSchema(options.config.schema)
: undefined,
options.configSchema ? createConfigSchema(options.configSchema) : undefined,
);
return OpaqueExtensionDefinition.createInstance('v2', {
T: undefined as unknown as {
config: string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<TConfigSchema[key]>
>;
};
configInput: string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>;
output: UOutput;
inputs: TInputs;
kind: string | undefined extends TKind ? undefined : TKind;
name: string | undefined extends TName ? undefined : TName;
},
T: undefined as any,
kind: options.kind,
name: options.name,
attachTo: options.attachTo,
@@ -480,7 +639,7 @@ export function createExtension<
if: options.if,
inputs: bindInputs(options.inputs, options.kind, options.name),
output: options.output,
configSchema,
configSchema: resolvedConfigSchema,
factory: options.factory,
toString() {
const parts: string[] = [];
@@ -527,13 +686,17 @@ export function createExtension<
parts.push(`attachTo=${attachTo}`);
return `ExtensionDefinition{${parts.join(',')}}`;
},
override(overrideOptions) {
override(overrideOptions: any) {
if (!Array.isArray(options.output)) {
throw new Error(
'Cannot override an extension that is not declared using the new format with outputs as an array',
);
}
if (overrideOptions.config?.schema) {
warnConfigSchemaPropDeprecation(describeParentCallSite());
}
// TODO(Rugvip): Making this a type check would be optimal, but it seems
// like it's tricky to add that and still have the type
// inference work correctly for the factory output.
@@ -572,7 +735,7 @@ export function createExtension<
output: (overrideOptions.output ??
options.output) as ExtensionDataRef[],
config:
options.config || overrideOptions.config
options.config?.schema || overrideOptions.config?.schema
? {
schema: {
...options.config?.schema,
@@ -580,6 +743,13 @@ export function createExtension<
},
}
: undefined,
configSchema:
options.configSchema || overrideOptions.configSchema
? {
...options.configSchema,
...overrideOptions.configSchema,
}
: undefined,
factory: ({ node, apis, config, inputs }) => {
if (!overrideOptions.factory) {
return options.factory({
@@ -591,8 +761,8 @@ export function createExtension<
});
}
const parentResult = overrideOptions.factory(
(innerContext): ExtensionDataContainer<UOutput> => {
return createExtensionDataContainer<UOutput>(
(innerContext: any): ExtensionDataContainer<any> => {
return createExtensionDataContainer<any>(
options.factory({
node,
apis,
@@ -31,6 +31,7 @@ import {
import { createExtensionInput } from './createExtensionInput';
import { RouteRef } from '../routing';
import { createExtension, ExtensionDefinition } from './createExtension';
import { z as zodV4 } from 'zod/v4';
import {
createExtensionDataContainer,
OpaqueExtensionDefinition,
@@ -248,6 +249,7 @@ describe('createExtensionBlueprint', () => {
},
});
// @ts-expect-error: overlapping config key 'text'
TestExtensionBlueprint.makeWithOverrides({
name: 'my-extension',
params: {
@@ -255,9 +257,8 @@ describe('createExtensionBlueprint', () => {
},
config: {
schema: {
// @ts-expect-error
text: z => z.number(),
something: z => z.string(),
text: (z: any) => z.number(),
something: (z: any) => z.string(),
},
},
});
@@ -309,6 +310,73 @@ describe('createExtensionBlueprint', () => {
);
});
it('should merge configSchema from blueprint with deprecated config.schema from override', () => {
const TestBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: [coreExtensionData.reactElement],
configSchema: {
title: zodV4.string().default('default title'),
},
factory(_, { config }) {
return [
coreExtensionData.reactElement(<div>{String(config.title)}</div>),
];
},
});
const extension = TestBlueprint.makeWithOverrides({
name: 'my-extension',
config: {
schema: {
extra: z => z.string(),
},
},
factory(origFactory, { config }) {
const c = config as { title: string; extra: string };
expect(c.title).toBe('default title');
expect(c.extra).toBe('extra value');
return origFactory({});
},
});
expect.assertions(2);
renderInTestApp(
createExtensionTester(extension, {
config: { extra: 'extra value' },
}).reactElement(),
);
});
it('should emit a deprecation warning when using config.schema', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
createExtension({
name: 'test-deprecated-warning',
attachTo: { id: 'test', input: 'default' },
output: [coreExtensionData.reactElement],
config: {
schema: {
title: z => z.string().default('hello'),
},
},
factory() {
return [coreExtensionData.reactElement(<div />)];
},
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'DEPRECATION WARNING: The `config.schema` option for extension config is deprecated',
),
);
} finally {
warnSpy.mockRestore();
}
});
it('should allow getting inputs properly', () => {
createExtensionBlueprint({
kind: 'test-extension',
@@ -28,6 +28,7 @@ import {
} from './createExtension';
import type { z } from 'zod/v3';
import { ExtensionInput } from './createExtensionInput';
import { type StandardSchemaV1 } from '@standard-schema/spec';
import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef';
import { createExtensionDataContainer } from '@internal/frontend';
import {
@@ -37,6 +38,8 @@ import {
import { ExtensionDataContainer } from './types';
import { PageBlueprint } from '../blueprints/PageBlueprint';
import { FilterPredicate } from '@backstage/filter-predicates';
import { warnConfigSchemaPropDeprecation } from '../schema/createPortableSchema';
import { describeParentCallSite } from '../routing/describeParentCallSite';
/**
* A function used to define a parameter mapping function in order to facilitate
@@ -110,6 +113,7 @@ export type CreateExtensionBlueprintOptions<
UFactoryOutput extends ExtensionDataValue<any, any>,
TDataRefs extends { [name in string]: ExtensionDataRef },
UParentInputs extends ExtensionDataRef,
TNewConfigSchema extends { [key in string]: StandardSchemaV1 } = {},
> = {
kind: TKind;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
@@ -118,7 +122,14 @@ export type CreateExtensionBlueprintOptions<
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
configSchema?: TNewConfigSchema;
/**
* @deprecated Use {@link CreateExtensionBlueprintOptions.configSchema} instead.
*/
config?: {
/**
* @deprecated Use {@link CreateExtensionBlueprintOptions.configSchema} instead.
*/
schema: TConfigSchema;
};
/**
@@ -170,7 +181,13 @@ export type CreateExtensionBlueprintOptions<
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
} & {
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
@@ -245,6 +262,92 @@ export interface ExtensionBlueprint<
* You must either pass `params` directly, or define a `factory` that can
* optionally call the original factory with the same params.
*/
makeWithOverrides<
TName extends string | undefined,
UFactoryOutput extends ExtensionDataValue<any, any>,
UNewOutput extends ExtensionDataRef,
UParentInputs extends ExtensionDataRef,
TExtraInputs extends { [inputName in string]: ExtensionInput } = {},
TNewExtensionConfigSchema extends {
[key in string]: StandardSchemaV1;
} = {},
>(args: {
name?: TName;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: never;
configSchema?: TNewExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
factory(
originalFactory: <
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
>(
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: T['params'],
context?: {
config?: T['config'];
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
},
) => ExtensionDataContainer<NonNullable<T['output']>>,
context: {
node: AppNode;
apis: ApiHolder;
config: T['config'] & {
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
},
): Iterable<UFactoryOutput> &
VerifyExtensionFactoryOutput<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UFactoryOutput
>;
}): OverridableExtensionDefinition<{
config: Expand<
{
[key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput<
TNewExtensionConfigSchema[key]
>;
} & T['config']
>;
configInput: Expand<
{
[key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput<
TNewExtensionConfigSchema[key]
>;
} & T['configInput']
>;
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: Expand<T['inputs'] & TExtraInputs>;
kind: T['kind'];
name: string | undefined extends TName ? undefined : TName;
params: T['params'];
}>;
/**
* @deprecated Use the `configSchema` option instead of `config.schema`.
*/
makeWithOverrides<
TName extends string | undefined,
TExtensionConfigSchema extends {
@@ -270,6 +373,7 @@ export interface ExtensionBlueprint<
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
configSchema?: never;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
@@ -295,7 +399,7 @@ export interface ExtensionBlueprint<
apis: ApiHolder;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
@@ -313,7 +417,9 @@ export interface ExtensionBlueprint<
? {}
: {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
ReturnType<
((...args: any[]) => any) & TExtensionConfigSchema[key]
>
>;
}) &
T['config']
@@ -324,7 +430,7 @@ export interface ExtensionBlueprint<
: z.input<
z.ZodObject<{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
((...args: any[]) => any) & TExtensionConfigSchema[key]
>;
}>
>) &
@@ -419,7 +525,7 @@ function unwrapParams<TParams extends object>(
* in the frontend system documentation.
*
* Extension blueprints make it much easier for users to create new extensions
* for your plugin. Rather than letting them use {@link createExtension}
* for your plugin. Rather than letting them use `createExtension`
* directly, you can define a set of parameters and default factory for your
* blueprint, removing a lot of the boilerplate and complexity that is otherwise
* needed to create an extension.
@@ -457,6 +563,74 @@ function unwrapParams<TParams extends object>(
* ```
* @public
*/
export function createExtensionBlueprint<
TParams extends object | ExtensionBlueprintDefineParams,
UOutput extends ExtensionDataRef,
TInputs extends { [inputName in string]: ExtensionInput },
UFactoryOutput extends ExtensionDataValue<any, any>,
TKind extends string,
UParentInputs extends ExtensionDataRef,
TDataRefs extends { [name in string]: ExtensionDataRef } = never,
TNewConfigSchema extends { [key in string]: StandardSchemaV1 } = {},
>(
options: {
kind: TKind;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: never;
configSchema?: TNewConfigSchema;
defineParams?: TParams extends ExtensionBlueprintDefineParams
? TParams
: 'The defineParams option must be a function if provided, see the docs for details';
factory(
params: TParams extends ExtensionBlueprintDefineParams
? ReturnType<TParams>['T']
: TParams,
context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Iterable<UFactoryOutput>;
dataRefs?: TDataRefs;
} & VerifyExtensionFactoryOutput<UOutput, UFactoryOutput>,
): ExtensionBlueprint<{
kind: TKind;
params: TParams;
output: UOutput extends ExtensionDataRef<
infer IData,
infer IId,
infer IConfig
>
? ExtensionDataRef<IData, IId, IConfig>
: never;
inputs: string extends keyof TInputs ? {} : TInputs;
config: {
[key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput<
TNewConfigSchema[key]
>;
};
configInput: {
[key in keyof TNewConfigSchema]?: StandardSchemaV1.InferInput<
TNewConfigSchema[key]
>;
};
dataRefs: TDataRefs;
}>;
/**
* @deprecated Use the top-level `configSchema` option instead of `config.schema`.
* @public
*/
export function createExtensionBlueprint<
TParams extends object | ExtensionBlueprintDefineParams,
UOutput extends ExtensionDataRef,
@@ -467,20 +641,41 @@ export function createExtensionBlueprint<
UParentInputs extends ExtensionDataRef,
TDataRefs extends { [name in string]: ExtensionDataRef } = never,
>(
options: CreateExtensionBlueprintOptions<
TKind,
TParams,
UOutput,
TInputs,
TConfigSchema,
UFactoryOutput,
TDataRefs,
UParentInputs
>,
options: {
kind: TKind;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
configSchema?: never;
config?: {
schema: TConfigSchema;
};
defineParams?: TParams extends ExtensionBlueprintDefineParams
? TParams
: 'The defineParams option must be a function if provided, see the docs for details';
factory(
params: TParams extends ExtensionBlueprintDefineParams
? ReturnType<TParams>['T']
: TParams,
context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Iterable<UFactoryOutput>;
dataRefs?: TDataRefs;
} & VerifyExtensionFactoryOutput<UOutput, UFactoryOutput>,
): ExtensionBlueprint<{
kind: TKind;
params: TParams;
// This inference and remapping back to ExtensionDataRef eliminates any occurrences ConfigurationExtensionDataRef
output: UOutput extends ExtensionDataRef<
infer IData,
infer IId,
@@ -491,16 +686,25 @@ export function createExtensionBlueprint<
inputs: string extends keyof TInputs ? {} : TInputs;
config: string extends keyof TConfigSchema
? {}
: { [key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>> };
: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<((...args: any[]) => any) & TConfigSchema[key]>
>;
};
configInput: string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
[key in keyof TConfigSchema]: ReturnType<
((...args: any[]) => any) & TConfigSchema[key]
>;
}>
>;
dataRefs: TDataRefs;
}> {
}>;
/** @internal */
export function createExtensionBlueprint(options: any): any {
const defineParams = options.defineParams as
| ExtensionBlueprintDefineParams
| undefined;
@@ -518,14 +722,18 @@ export function createExtensionBlueprint<
inputs: options.inputs,
output: options.output as ExtensionDataRef[],
config: options.config,
configSchema: options.configSchema as any,
factory: ctx =>
options.factory(
unwrapParams(args.params, ctx, defineParams, options.kind),
ctx,
ctx as any,
) as Iterable<ExtensionDataValue<any, any>>,
}) as OverridableExtensionDefinition;
},
makeWithOverrides(args) {
makeWithOverrides(args: any) {
if (args.config?.schema) {
warnConfigSchemaPropDeprecation(describeParentCallSite());
}
return createExtension({
kind: options.kind,
name: args.name,
@@ -536,7 +744,7 @@ export function createExtensionBlueprint<
inputs: { ...args.inputs, ...options.inputs },
output: (args.output ?? options.output) as ExtensionDataRef[],
config:
options.config || args.config
options.config?.schema || args.config?.schema
? {
schema: {
...options.config?.schema,
@@ -544,19 +752,18 @@ export function createExtensionBlueprint<
},
}
: undefined,
configSchema:
options.configSchema || args.configSchema
? {
...options.configSchema,
...args.configSchema,
}
: (undefined as any),
factory: ctx => {
const { node, config, inputs, apis } = ctx;
return args.factory(
(innerParams, innerContext) => {
return createExtensionDataContainer<
UOutput extends ExtensionDataRef<
infer IData,
infer IId,
infer IConfig
>
? ExtensionDataRef<IData, IId, IConfig>
: never
>(
(innerParams: any, innerContext: any) => {
return createExtensionDataContainer<any>(
options.factory(
unwrapParams(innerParams, ctx, defineParams, options.kind),
{
@@ -584,23 +791,5 @@ export function createExtensionBlueprint<
},
}) as OverridableExtensionDefinition;
},
} as ExtensionBlueprint<{
kind: TKind;
params: TParams;
output: any;
inputs: string extends keyof TInputs ? {} : TInputs;
config: string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
configInput: string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>;
dataRefs: TDataRefs;
}>;
} as ExtensionBlueprint<any>;
}