diff --git a/.changeset/catalog-react-configschema-migration.md b/.changeset/catalog-react-configschema-migration.md index 766dbef24f..5cf7506f60 100644 --- a/.changeset/catalog-react-configschema-migration.md +++ b/.changeset/catalog-react-configschema-migration.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-react': patch --- -Migrated alpha entity blueprints to use the new `configSchema` option with direct zod schema values. +Migrated alpha entity blueprints to use the new `configSchema` option with zod v4 schema values. diff --git a/.changeset/filter-predicates-v4-schema.md b/.changeset/filter-predicates-v4-schema.md new file mode 100644 index 0000000000..f72a3b1158 --- /dev/null +++ b/.changeset/filter-predicates-v4-schema.md @@ -0,0 +1,5 @@ +--- +'@backstage/filter-predicates': patch +--- + +Added `createZodV4FilterPredicateSchema` as a zod v4 counterpart to `createZodV3FilterPredicateSchema`. diff --git a/.changeset/frontend-plugin-api-configschema-option.md b/.changeset/frontend-plugin-api-configschema-option.md index 03587b7b2b..85af4ccdad 100644 --- a/.changeset/frontend-plugin-api-configschema-option.md +++ b/.changeset/frontend-plugin-api-configschema-option.md @@ -2,4 +2,4 @@ '@backstage/frontend-plugin-api': patch --- -Added a new `configSchema` option for `createExtension` and `createExtensionBlueprint` that accepts direct schema values from any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library. The old `config.schema` option is now deprecated. See the [1.50 migration documentation](https://backstage.io/docs/frontend-system/architecture/migrations#150) for more information. +Added a new `configSchema` option for `createExtension` and `createExtensionBlueprint` that accepts direct schema values from any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library with JSON Schema support, such as zod v4 or the `zod/v4` subpath from zod v3. The old `config.schema` option is now deprecated. Note that direct zod v3 schemas are not supported by the new `configSchema` option — use `import { z } from 'zod/v4'` from the zod v3 package, or upgrade to zod v4. See the [1.50 migration documentation](https://backstage.io/docs/frontend-system/architecture/migrations#150) for more information. diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 35b166115e..35bbd50489 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -275,7 +275,7 @@ In addition to being able to access data passed through the input, you also have ## Extension configuration -With the `app-config.yaml` there is already the option to pass configuration to plugins or the app to e.g. define the `baseURL` of your app. For extensions this concept would be limiting as an extension can be independent of the plugin & initiated several times. Therefore we created a possibility to configure each extension individually through config. The extension config schema is created using the [`zod`](https://zod.dev/) library, which in addition to TypeScript type checking also provides runtime validation and coercion. If we continue with the example of the `navigationExtension` and now want it to contain a configurable title, we could make it available like the following: +With the `app-config.yaml` there is already the option to pass configuration to plugins or the app to e.g. define the `baseURL` of your app. For extensions this concept would be limiting as an extension can be independent of the plugin & initiated several times. Therefore we created a possibility to configure each extension individually through config. The extension config schema is created using any schema library that implements the [Standard Schema](https://github.com/standard-schema/standard-schema) interface with JSON Schema support, such as [`zod`](https://zod.dev/) v4 (or `import { z } from 'zod/v4'` from the zod v3 package). In addition to TypeScript type checking, the schema also provides runtime validation and coercion. If we continue with the example of the `navigationExtension` and now want it to contain a configurable title, we could make it available like the following: ```tsx import { z } from 'zod'; diff --git a/docs/frontend-system/architecture/60-migrations.md b/docs/frontend-system/architecture/60-migrations.md index 3ea7f9351e..634eeb271e 100644 --- a/docs/frontend-system/architecture/60-migrations.md +++ b/docs/frontend-system/architecture/60-migrations.md @@ -15,9 +15,9 @@ This guide is intended for app and plugin authors who have already migrated thei ### New `configSchema` option for extension config -The `config.schema` option for `createExtension` and `createExtensionBlueprint` is now deprecated in favor of a new top-level `configSchema` option. The new option accepts direct schema values from any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library, rather than requiring factory functions. The `createSchemaFromZod` helper has also been removed. +The `config.schema` option for `createExtension` and `createExtensionBlueprint` is now deprecated in favor of a new top-level `configSchema` option. The new option accepts direct schema values from any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library with JSON Schema support, rather than requiring factory functions. The `createSchemaFromZod` helper has also been removed. -The recommended migration target is [zod v4](https://zod.dev/), but any Standard Schema compatible library or version works, including zod v3.25+. +The `configSchema` option requires schemas that implement the Standard Schema interface with JSON Schema support. This means you need to use [zod v4](https://zod.dev/) or the `zod/v4` subpath export from the zod v3 package (v3.25+). Direct zod v3 schemas are **not** supported by the new `configSchema` option — they are only supported through the deprecated `config.schema` callback format. For example, an extension previously declared like this: @@ -36,10 +36,13 @@ createExtension({ }); ``` -Should now look like this: +Should now look like this, using zod v4 or the `zod/v4` subpath: ```tsx +// Either import from zod v4 directly: import { z } from 'zod'; +// Or use the v4 subpath from the zod v3 package: +// import { z } from 'zod/v4'; createExtension({ // ... @@ -86,7 +89,7 @@ MyBlueprint.makeWithOverrides({ }); ``` -Each field in the `configSchema` record is a standalone schema value rather than a factory function. This decouples the config schema declaration from any specific zod version, and lets you use any schema library that implements the Standard Schema interface. +Each field in the `configSchema` record is a standalone schema value rather than a factory function. This decouples the config schema declaration from any specific zod version, and lets you use any schema library that implements the Standard Schema interface with JSON Schema support. ## 1.31 diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index 76adba09ec..b400d6f6aa 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -27,10 +27,10 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'signIn.title': 'Sign In'; readonly 'signIn.loginFailed': 'Login failed'; readonly 'signIn.customProvider.title': 'Custom User'; + readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.subtitle': 'Enter your own User ID and credentials.\n This selection will not be stored.'; readonly 'signIn.customProvider.userId': 'User ID'; readonly 'signIn.customProvider.tokenInvalid': 'Token is not a valid OpenID Connect JWT Token'; - readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.idToken': 'ID Token (optional)'; readonly 'signIn.guestProvider.title': 'Guest'; readonly 'signIn.guestProvider.enter': 'Enter'; diff --git a/packages/core-components/report.api.md b/packages/core-components/report.api.md index ac6dc5ca15..8ddacb1b96 100644 --- a/packages/core-components/report.api.md +++ b/packages/core-components/report.api.md @@ -245,10 +245,10 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'signIn.title': 'Sign In'; readonly 'signIn.loginFailed': 'Login failed'; readonly 'signIn.customProvider.title': 'Custom User'; + readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.subtitle': 'Enter your own User ID and credentials.\n This selection will not be stored.'; readonly 'signIn.customProvider.userId': 'User ID'; readonly 'signIn.customProvider.tokenInvalid': 'Token is not a valid OpenID Connect JWT Token'; - readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.idToken': 'ID Token (optional)'; readonly 'signIn.guestProvider.title': 'Guest'; readonly 'signIn.guestProvider.enter': 'Enter'; diff --git a/packages/filter-predicates/report.api.md b/packages/filter-predicates/report.api.md index 76edeb61d2..0aea83072f 100644 --- a/packages/filter-predicates/report.api.md +++ b/packages/filter-predicates/report.api.md @@ -5,6 +5,7 @@ ```ts import { Config } from '@backstage/config'; import { JsonValue } from '@backstage/types'; +import { z } from 'zod/v4'; import * as zodV3 from 'zod/v3'; // @public @@ -12,6 +13,12 @@ export function createZodV3FilterPredicateSchema( z: typeof zodV3.z, ): zodV3.ZodType; +// @public +export function createZodV4FilterPredicateSchema(): z.ZodType< + FilterPredicate, + FilterPredicate +>; + // @public export function evaluateFilterPredicate( predicate: FilterPredicate, diff --git a/packages/filter-predicates/src/predicates/index.ts b/packages/filter-predicates/src/predicates/index.ts index 5684c7e62f..57a38f340a 100644 --- a/packages/filter-predicates/src/predicates/index.ts +++ b/packages/filter-predicates/src/predicates/index.ts @@ -26,6 +26,7 @@ export { export { getJsonValueAtPath } from './getJsonValueAtPath'; export { createZodV3FilterPredicateSchema, + createZodV4FilterPredicateSchema, parseFilterPredicate, } from './schema'; export type { diff --git a/packages/filter-predicates/src/predicates/schema.ts b/packages/filter-predicates/src/predicates/schema.ts index 81652786c6..8526748472 100644 --- a/packages/filter-predicates/src/predicates/schema.ts +++ b/packages/filter-predicates/src/predicates/schema.ts @@ -17,6 +17,7 @@ import { InputError } from '@backstage/errors'; import { fromZodError } from 'zod-validation-error/v3'; import * as zodV3 from 'zod/v3'; +import { z as zodV4 } from 'zod/v4'; import { FilterPredicate, FilterPredicateExpression, @@ -69,6 +70,57 @@ export function createZodV3FilterPredicateSchema( return predicateSchema; } +/** + * Creates a zod v4 schema for validating filter predicates. + * + * @public + */ +export function createZodV4FilterPredicateSchema(): zodV4.ZodType< + FilterPredicate, + FilterPredicate +> { + const z = zodV4; + + const primitiveSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + ]) as zodV4.ZodType; + + // eslint-disable-next-line prefer-const + let valuePredicateSchema: zodV4.ZodType< + FilterPredicateValue, + FilterPredicateValue + >; + + const expressionSchema = z.lazy(() => + z.union([ + z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema), + z.record(z.string().regex(/^\$/), z.never()), + ]), + ) as zodV4.ZodType; + + const predicateSchema = z.lazy(() => + z.union([ + expressionSchema, + primitiveSchema, + z.object({ $all: z.array(predicateSchema) }), + z.object({ $any: z.array(predicateSchema) }), + z.object({ $not: predicateSchema }), + ]), + ) as zodV4.ZodType; + + valuePredicateSchema = z.union([ + primitiveSchema, + z.object({ $exists: z.boolean() }), + z.object({ $in: z.array(primitiveSchema) }), + z.object({ $contains: predicateSchema }), + z.object({ $hasPrefix: z.string() }), + ]) as zodV4.ZodType; + + return predicateSchema; +} + /** * Parses a value to check that it's a valid filter predicate. * diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index e8aca98db8..9a60e55033 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -40,7 +40,7 @@ import { resolveExtensionDefinition, } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { createConfigSchema } from '../../../frontend-plugin-api/src/schema/createPortableSchema'; +import { createDeprecatedConfigSchema } from '../../../frontend-plugin-api/src/schema/createPortableSchema'; import { TestApiRegistry, withLogCollector } from '@backstage/test-utils'; import { createErrorCollector } from '../wiring/createErrorCollector'; @@ -181,7 +181,7 @@ describe('instantiateAppNodeTree', () => { test: testDataRef, other: otherDataRef.optional(), }, - configSchema: createConfigSchema({ + configSchema: createDeprecatedConfigSchema({ output: z => z.string().default('test'), other: z => z.number().optional(), }), diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts index a763c6e188..c42298585b 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts @@ -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'], + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index 2efa93b2e3..a6bb3340d7 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -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 ZodType>, +): MergeablePortableSchema { + const resolved: Record = {}; + + 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 * @internal */ export function createConfigSchema( - fields: Record ZodType)>, + fields: Record, ): MergeablePortableSchema { const resolved: Record = {}; 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( */ 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}`, ); } diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index fd6015c016..6735fc9e5d 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -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 { - 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, diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index dcb2df12ce..17405c8b9d 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -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 [ diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index aba81d64f3..99ed7c1afb 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -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), diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 949f34e48f..e6d68a31be 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -28,11 +28,11 @@ import { } from './extensionData'; import { FilterPredicate, - createZodV3FilterPredicateSchema, + createZodV4FilterPredicateSchema, } from '@backstage/filter-predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { Entity } from '@backstage/catalog-model'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; /** * @alpha @@ -54,7 +54,7 @@ export const EntityCardBlueprint = createExtensionBlueprint({ }, configSchema: { filter: z - .union([z.string(), createZodV3FilterPredicateSchema(z)]) + .union([z.string(), createZodV4FilterPredicateSchema()]) .optional(), type: z.enum(entityCardTypes).optional(), }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index d41f14ff21..d534ee0f2b 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -30,12 +30,12 @@ import { } from './extensionData'; import { FilterPredicate, - createZodV3FilterPredicateSchema, + createZodV4FilterPredicateSchema, } from '@backstage/filter-predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { Entity } from '@backstage/catalog-model'; import { ReactElement } from 'react'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; /** * @alpha @@ -65,7 +65,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ path: z.string().optional(), title: z.string().optional(), filter: z - .union([z.string(), createZodV3FilterPredicateSchema(z)]) + .union([z.string(), createZodV4FilterPredicateSchema()]) .optional(), group: z.literal(false).or(z.string()).optional(), icon: z.string().optional(),