Drop Zod v3 support from new configSchema path
The new `configSchema` option now strictly requires StandardSchemaV1 values (e.g. Zod v4 or `zod/v4` from the Zod v3 package). Direct Zod v3 schemas are no longer silently converted and will throw an error. The deprecated `config.schema` callback path continues to work with Zod v3 through a separate `createDeprecatedConfigSchema` function. Also adds `createZodV4FilterPredicateSchema` to `@backstage/filter-predicates` as a v4 counterpart to the now-deprecated v3 variant. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -14,77 +14,15 @@
|
||||
* 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 v3 schemas', () => {
|
||||
it('should report a missing required field', () => {
|
||||
const schema = createConfigSchema({ 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 = createConfigSchema({ 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 = createConfigSchema({
|
||||
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 = createConfigSchema({
|
||||
value: z => z.union([z.string(), z.number()]),
|
||||
});
|
||||
|
||||
expect(() => schema.parse({})).toThrow(
|
||||
"Missing required value at 'value'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should combine errors from multiple fields', () => {
|
||||
const schema = createConfigSchema({
|
||||
name: z => z.string(),
|
||||
count: z => z.number(),
|
||||
});
|
||||
|
||||
expect(() => schema.parse({})).toThrow(
|
||||
"Missing required value at 'name'; Missing required value at 'count'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply defaults for optional fields with defaults', () => {
|
||||
const schema = createConfigSchema({
|
||||
name: z => z.string(),
|
||||
mode: z => z.enum(['fast', 'slow']).default('fast'),
|
||||
});
|
||||
|
||||
expect(schema.parse({ name: 'test' })).toEqual({
|
||||
name: 'test',
|
||||
mode: 'fast',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('zod v4 schemas', () => {
|
||||
it('should report a missing required field', () => {
|
||||
const schema = createConfigSchema({ name: zodV4.string() });
|
||||
@@ -150,57 +88,22 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed zod v3 and v4 schemas', () => {
|
||||
it('should validate fields from both schema versions', () => {
|
||||
const schema = createConfigSchema({
|
||||
v3field: z => z.string(),
|
||||
v4field: zodV4.number(),
|
||||
});
|
||||
|
||||
expect(schema.parse({ v3field: 'hello', v4field: 42 })).toEqual({
|
||||
v3field: 'hello',
|
||||
v4field: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('should report errors from both schema versions', () => {
|
||||
const schema = createConfigSchema({
|
||||
v3field: z => z.string(),
|
||||
v4field: zodV4.number(),
|
||||
});
|
||||
|
||||
expect(() => schema.parse({})).toThrow(
|
||||
"Missing required value at 'v3field'; " +
|
||||
"Invalid input: expected number, received undefined at 'v4field'",
|
||||
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.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce correct JSON Schema for mixed schemas', () => {
|
||||
const schema = createConfigSchema({
|
||||
v3field: z => z.string(),
|
||||
v4field: zodV4.number().optional(),
|
||||
});
|
||||
|
||||
const result = schema.schema();
|
||||
expect(result.schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
v3field: { type: 'string' },
|
||||
v4field: { type: 'number' },
|
||||
},
|
||||
required: ['v3field'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema creation errors', () => {
|
||||
it('should reject a schema that is not a valid Standard Schema or zod schema', () => {
|
||||
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 or zod schema",
|
||||
);
|
||||
).toThrow("Config schema for field 'bad' is not a valid Standard Schema");
|
||||
});
|
||||
|
||||
it('should reject a Standard Schema without JSON Schema support', () => {
|
||||
@@ -223,8 +126,8 @@ describe('createConfigSchema', () => {
|
||||
describe('JSON Schema generation', () => {
|
||||
it('should generate JSON Schema lazily via schema()', () => {
|
||||
const schema = createConfigSchema({
|
||||
title: z => z.string(),
|
||||
count: z => z.number().optional(),
|
||||
title: zodV4.string(),
|
||||
count: zodV4.number().optional(),
|
||||
});
|
||||
|
||||
const result = schema.schema();
|
||||
@@ -242,7 +145,7 @@ describe('createConfigSchema', () => {
|
||||
|
||||
it('should support backward-compatible property access on schema', () => {
|
||||
const schema = createConfigSchema({
|
||||
title: z => z.string(),
|
||||
title: zodV4.string(),
|
||||
});
|
||||
|
||||
expect(schema.schema.type).toBe('object');
|
||||
@@ -251,9 +154,9 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
|
||||
describe('merging schemas', () => {
|
||||
it('should merge two zod v3 schemas and parse correctly', () => {
|
||||
const a = createConfigSchema({ name: z => z.string() });
|
||||
const b = createConfigSchema({ count: z => z.number().default(0) });
|
||||
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({
|
||||
@@ -262,8 +165,8 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge zod v3 and v4 schemas', () => {
|
||||
const a = createConfigSchema({ name: z => z.string() });
|
||||
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)!;
|
||||
@@ -274,7 +177,7 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
|
||||
it('should produce combined errors after merge', () => {
|
||||
const a = createConfigSchema({ name: z => z.string() });
|
||||
const a = createDeprecatedConfigSchema({ name: z => z.string() });
|
||||
const b = createConfigSchema({ count: zodV4.number() });
|
||||
|
||||
const merged = mergePortableSchemas(a, b)!;
|
||||
@@ -286,7 +189,7 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
|
||||
it('should produce combined errors for type mismatches after merge', () => {
|
||||
const a = createConfigSchema({ name: z => z.string() });
|
||||
const a = createDeprecatedConfigSchema({ name: z => z.string() });
|
||||
const b = createConfigSchema({ count: zodV4.number() });
|
||||
|
||||
const merged = mergePortableSchemas(a, b)!;
|
||||
@@ -298,7 +201,7 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
|
||||
it('should produce correct JSON Schema after merge', () => {
|
||||
const a = createConfigSchema({ name: z => z.string() });
|
||||
const a = createDeprecatedConfigSchema({ name: z => z.string() });
|
||||
const b = createConfigSchema({ count: zodV4.number().optional() });
|
||||
|
||||
const merged = mergePortableSchemas(a, b)!;
|
||||
@@ -316,7 +219,7 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
|
||||
it('should handle merge with undefined', () => {
|
||||
const a = createConfigSchema({ name: z => z.string() });
|
||||
const a = createConfigSchema({ name: zodV4.string() });
|
||||
|
||||
expect(mergePortableSchemas(a, undefined)).toBe(a);
|
||||
expect(mergePortableSchemas(undefined, a)).toBe(a);
|
||||
@@ -324,7 +227,7 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
|
||||
it('should let later fields win when merging overlapping keys', () => {
|
||||
const a = createConfigSchema({ x: z => z.string() });
|
||||
const a = createDeprecatedConfigSchema({ x: z => z.string() });
|
||||
const b = createConfigSchema({ x: zodV4.number() });
|
||||
|
||||
const merged = mergePortableSchemas(a, b)!;
|
||||
@@ -351,7 +254,7 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
|
||||
it('should throw a clear error for non-object input', () => {
|
||||
const schema = createConfigSchema({ title: z => z.string() });
|
||||
const schema = createConfigSchema({ title: zodV4.string() });
|
||||
|
||||
expect(() => schema.parse('not an object')).toThrow(
|
||||
'Invalid config input, expected object but got string',
|
||||
@@ -369,9 +272,9 @@ describe('createConfigSchema', () => {
|
||||
|
||||
it('should not produce undefined keys for absent optional fields', () => {
|
||||
const schema = createConfigSchema({
|
||||
name: z => z.string(),
|
||||
title: z => z.string().optional(),
|
||||
count: z => z.number().default(42),
|
||||
name: zodV4.string(),
|
||||
title: zodV4.string().optional(),
|
||||
count: zodV4.number().default(42),
|
||||
});
|
||||
|
||||
const result = schema.parse({ name: 'hello' });
|
||||
@@ -379,11 +282,11 @@ describe('createConfigSchema', () => {
|
||||
expect(Object.keys(result as object)).toEqual(['name', 'count']);
|
||||
});
|
||||
|
||||
it('should not mark defaulted zod v3 fields as required in JSON Schema', () => {
|
||||
it('should not mark defaulted fields as required in JSON Schema', () => {
|
||||
const schema = createConfigSchema({
|
||||
name: z => z.string(),
|
||||
title: z => z.string().default('hello'),
|
||||
count: z => z.number().optional(),
|
||||
name: zodV4.string(),
|
||||
title: zodV4.string().default('hello'),
|
||||
count: zodV4.number().optional(),
|
||||
});
|
||||
|
||||
const result = schema.schema();
|
||||
@@ -394,3 +297,85 @@ describe('createConfigSchema', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,19 @@ import { PortableSchema } from './types';
|
||||
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
|
||||
@@ -53,13 +66,12 @@ export interface MergeablePortableSchema<TOutput = any, TInput = any>
|
||||
* @internal
|
||||
*/
|
||||
export function createConfigSchema(
|
||||
fields: Record<string, StandardSchemaV1 | ((zImpl: typeof zodV3) => ZodType)>,
|
||||
fields: Record<string, StandardSchemaV1>,
|
||||
): MergeablePortableSchema {
|
||||
const resolved: Record<string, ResolvedField> = {};
|
||||
|
||||
for (const [key, field] of Object.entries(fields)) {
|
||||
const schema = typeof field === 'function' ? field(zodV3) : field;
|
||||
resolved[key] = resolveField(key, schema);
|
||||
resolved[key] = resolveField(key, field);
|
||||
}
|
||||
|
||||
return buildPortableSchema(resolved);
|
||||
@@ -165,20 +177,25 @@ function buildPortableSchema<TOutput = unknown>(
|
||||
*/
|
||||
function resolveField(key: string, schema: unknown): ResolvedField {
|
||||
if (isZodV3Type(schema)) {
|
||||
return resolveZodField(key, 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+), or use a zod v3 schema.`,
|
||||
`JSON Schema interface (like zod v4+).`,
|
||||
);
|
||||
}
|
||||
return resolveStandardField(key, schema);
|
||||
}
|
||||
throw new Error(
|
||||
`Config schema for field '${key}' is not a valid Standard Schema or zod schema`,
|
||||
`Config schema for field '${key}' is not a valid Standard Schema`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -340,7 +357,8 @@ export function warnConfigSchemaPropDeprecation(callSite: string) {
|
||||
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 v3.25+ or v4. ` +
|
||||
`\`configSchema: { title: z.string() }\` using zod v4 ` +
|
||||
`(or \`import { z } from 'zod/v4'\` from the zod v3 package). ` +
|
||||
`Declared at ${callSite}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import { ExtensionInput } from './createExtensionInput';
|
||||
import type { z } from 'zod/v3';
|
||||
import {
|
||||
createConfigSchema,
|
||||
createDeprecatedConfigSchema,
|
||||
mergePortableSchemas,
|
||||
warnConfigSchemaPropDeprecation,
|
||||
} from '../schema/createPortableSchema';
|
||||
import { describeParentCallSite } from '../routing/describeParentCallSite';
|
||||
@@ -618,13 +620,15 @@ export function createExtension<
|
||||
export function createExtension(
|
||||
options: any,
|
||||
): OverridableExtensionDefinition<any> {
|
||||
const schemaDeclaration =
|
||||
options.configSchema ?? (options.config?.schema as any);
|
||||
if (options.config?.schema) {
|
||||
warnConfigSchemaPropDeprecation(describeParentCallSite());
|
||||
}
|
||||
const resolvedConfigSchema =
|
||||
schemaDeclaration && createConfigSchema(schemaDeclaration);
|
||||
const resolvedConfigSchema = mergePortableSchemas(
|
||||
options.config?.schema
|
||||
? createDeprecatedConfigSchema(options.config.schema)
|
||||
: undefined,
|
||||
options.configSchema ? createConfigSchema(options.configSchema) : undefined,
|
||||
);
|
||||
|
||||
return OpaqueExtensionDefinition.createInstance('v2', {
|
||||
T: undefined as any,
|
||||
@@ -730,15 +734,19 @@ export function createExtension(
|
||||
),
|
||||
output: (overrideOptions.output ??
|
||||
options.output) as ExtensionDataRef[],
|
||||
configSchema:
|
||||
options.config ||
|
||||
options.configSchema ||
|
||||
overrideOptions.config ||
|
||||
overrideOptions.configSchema
|
||||
config:
|
||||
options.config?.schema || overrideOptions.config?.schema
|
||||
? {
|
||||
schema: {
|
||||
...options.config?.schema,
|
||||
...overrideOptions.config?.schema,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
configSchema:
|
||||
options.configSchema || overrideOptions.configSchema
|
||||
? {
|
||||
...options.config?.schema,
|
||||
...options.configSchema,
|
||||
...overrideOptions.config?.schema,
|
||||
...overrideOptions.configSchema,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { createExtensionInput } from './createExtensionInput';
|
||||
import { RouteRef } from '../routing';
|
||||
import { createExtension, ExtensionDefinition } from './createExtension';
|
||||
import { z as zodV3 } from 'zod/v3';
|
||||
import { z as zodV4 } from 'zod/v4';
|
||||
import {
|
||||
createExtensionDataContainer,
|
||||
OpaqueExtensionDefinition,
|
||||
@@ -316,7 +316,7 @@ describe('createExtensionBlueprint', () => {
|
||||
attachTo: { id: 'test', input: 'default' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
configSchema: {
|
||||
title: zodV3.string().default('default title'),
|
||||
title: zodV4.string().default('default title'),
|
||||
},
|
||||
factory(_, { config }) {
|
||||
return [
|
||||
|
||||
@@ -743,15 +743,19 @@ export function createExtensionBlueprint(options: any): any {
|
||||
if: args.if ?? options.if,
|
||||
inputs: { ...args.inputs, ...options.inputs },
|
||||
output: (args.output ?? options.output) as ExtensionDataRef[],
|
||||
configSchema:
|
||||
options.configSchema ||
|
||||
args.configSchema ||
|
||||
options.config?.schema ||
|
||||
args.config?.schema
|
||||
config:
|
||||
options.config?.schema || args.config?.schema
|
||||
? {
|
||||
schema: {
|
||||
...options.config?.schema,
|
||||
...args.config?.schema,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
configSchema:
|
||||
options.configSchema || args.configSchema
|
||||
? {
|
||||
...options.config?.schema,
|
||||
...options.configSchema,
|
||||
...args.config?.schema,
|
||||
...args.configSchema,
|
||||
}
|
||||
: (undefined as any),
|
||||
|
||||
Reference in New Issue
Block a user