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:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/filter-predicates': patch
|
||||
---
|
||||
|
||||
Added `createZodV4FilterPredicateSchema` as a zod v4 counterpart to `createZodV3FilterPredicateSchema`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<FilterPredicate>;
|
||||
|
||||
// @public
|
||||
export function createZodV4FilterPredicateSchema(): z.ZodType<
|
||||
FilterPredicate,
|
||||
FilterPredicate
|
||||
>;
|
||||
|
||||
// @public
|
||||
export function evaluateFilterPredicate(
|
||||
predicate: FilterPredicate,
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
export { getJsonValueAtPath } from './getJsonValueAtPath';
|
||||
export {
|
||||
createZodV3FilterPredicateSchema,
|
||||
createZodV4FilterPredicateSchema,
|
||||
parseFilterPredicate,
|
||||
} from './schema';
|
||||
export type {
|
||||
|
||||
@@ -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<FilterPredicatePrimitive, FilterPredicatePrimitive>;
|
||||
|
||||
// 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<FilterPredicateExpression, FilterPredicateExpression>;
|
||||
|
||||
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<FilterPredicate, FilterPredicate>;
|
||||
|
||||
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<FilterPredicateValue, FilterPredicateValue>;
|
||||
|
||||
return predicateSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a value to check that it's a valid filter predicate.
|
||||
*
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user