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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user