diff --git a/.changeset/app-configschema-migration.md b/.changeset/app-configschema-migration.md new file mode 100644 index 0000000000..7ccfd1c764 --- /dev/null +++ b/.changeset/app-configschema-migration.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Migrated `AppLanguageApi` extension to use the new `configSchema` option. diff --git a/.changeset/catalog-graph-docs-update.md b/.changeset/catalog-graph-docs-update.md new file mode 100644 index 0000000000..6dba2967ec --- /dev/null +++ b/.changeset/catalog-graph-docs-update.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-graph': patch +--- + +Updated `README-alpha.md` extension examples to use current APIs. diff --git a/.changeset/catalog-react-configschema-migration.md b/.changeset/catalog-react-configschema-migration.md new file mode 100644 index 0000000000..5cf7506f60 --- /dev/null +++ b/.changeset/catalog-react-configschema-migration.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +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/fix-zod-generic-frontend-plugin-api.md b/.changeset/fix-zod-generic-frontend-plugin-api.md index 8950ed70c9..00f74f5e18 100644 --- a/.changeset/fix-zod-generic-frontend-plugin-api.md +++ b/.changeset/fix-zod-generic-frontend-plugin-api.md @@ -1,5 +1,5 @@ --- -'@backstage/frontend-plugin-api': patch +'@backstage/frontend-plugin-api': minor --- -Refactored the internal `createSchemaFromZod` helper to use a schema-first generic pattern, replacing the `ZodSchema` constraint with `TSchema extends ZodType`. This avoids "excessively deep" type inference errors when multiple Zod copies are resolved. +**BREAKING**: Removed the deprecated `createSchemaFromZod` helper. Use the new `configSchema` option instead. See the [1.50 migration documentation](https://backstage.io/docs/frontend-system/architecture/migrations#150) for more information. diff --git a/.changeset/frontend-plugin-api-configschema-option.md b/.changeset/frontend-plugin-api-configschema-option.md new file mode 100644 index 0000000000..85af4ccdad --- /dev/null +++ b/.changeset/frontend-plugin-api-configschema-option.md @@ -0,0 +1,5 @@ +--- +'@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 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/features/search/declarative-integration.md b/docs/features/search/declarative-integration.md index 3ad7492114..cea5225f2f 100644 --- a/docs/features/search/declarative-integration.md +++ b/docs/features/search/declarative-integration.md @@ -71,30 +71,25 @@ app: ### Customizations -Plugin developers can use the `createSearchResultItemExtension` factory provided by the `@backstage/plugin-search-react` for building their own custom `Search` result item extensions. +Plugin developers can use the `SearchResultListItemBlueprint` from `@backstage/plugin-search-react/alpha` for building their own custom search result item extensions. _Example creating a custom `TechDocsSearchResultItemExtension`_ ```tsx -// plugins/techdocs/alpha.tsx -import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha'; +// plugins/techdocs/src/alpha.tsx +import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha'; -/** @alpha */ export const TechDocsSearchResultListItemExtension = - createSearchResultListItemExtension({ - id: 'techdocs', - configSchema: createSchemaFromZod(z => - z.object({ - noTrack: z.boolean().default(false), - lineClamp: z.number().default(5), - }), - ), - predicate: result => result.type === 'techdocs', - component: async ({ config }) => { - const { TechDocsSearchResultListItem } = await import( - './components/TechDocsSearchResultListItem' - ); - return props => ; + SearchResultListItemBlueprint.make({ + name: 'techdocs', + params: { + predicate: result => result.type === 'techdocs', + component: async ({ config }) => { + const { TechDocsSearchResultListItem } = await import( + './components/TechDocsSearchResultListItem' + ); + return props => ; + }, }, }); ``` @@ -107,110 +102,42 @@ When a Backstage adopter doesn't want to use the custom `TechDocs` search result # app-config.yaml app: extensions: - - plugin.search.result.item.techdocs: false # ✨ + - search-result-list-item:techdocs: false ``` -Because a configuration schema was provided to the extension factory, Backstage adopters will be able to customize `TechDocs` search results **line clamp** that defaults to 3 and also **disable automatic analytics events tracking**: +The `SearchResultListItemBlueprint` includes a built-in `noTrack` config option that can be used to **disable automatic analytics events tracking**: ```yaml # app-config.yaml app: extensions: - - plugin.search.result.item.techdocs: - config: # ✨ + - search-result-list-item:techdocs: + config: noTrack: true - lineClamp: 3 ``` -[comment]: <> (TODO: Extract this explanation to a more central place in the future) -The `createSearchResultItemExtension` function returns a Backstage's extension representation as follows: +To complete the development cycle for creating a custom search result item extension, provide the extension via the `TechDocs` plugin. You can also take a look at the [actual implementation](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/alpha/index.tsx) of a custom `TechDocs` search result item: -```ts -{ - "$$type": "@backstage/Extension", // [1] - "id": "plugin.search.result.item.techdocs", // [2] - "at": "plugin.search.page/items", // [3] - "inputs": {} // [4️] - "output": { // [5️] - "item": { - "$$type": "@backstage/ExtensionDataRef", - "id": "plugin.search.result.item.data", - "config": {} - } - }, - "configSchema": { // [6️] - "schema": { - "type": "object", - "properties": { - "noTrack": { - "type": "boolean", - "default": false - }, - "lineClamp": { - "type": "number", - "default": 5 - } +```tsx +// plugins/techdocs/src/alpha.tsx +import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha'; + +const TechDocsSearchResultListItemExtension = + SearchResultListItemBlueprint.make({ + name: 'techdocs', + params: { + predicate: result => result.type === 'techdocs', + component: async ({ config }) => { + const { TechDocsSearchResultListItem } = await import( + './components/TechDocsSearchResultListItem' + ); + return props => ; }, - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - "disabled": false, // [7️] -} -``` - -In this object, you can see exactly what will happen once the custom extension is installed: - -- **[1] `$$type`**: declares that the object represents an extension; -- **[2] `id`**: Is a unique identification for the extension, the `plugin.search.result.item.techdocs` is the key used to configure the extension in the `app-config.yaml` file; -- **[3] `at`**: It represents the extension attachment point, so the value `plugin.search.page/items` says that the `TechDocs`'s search result item output will be injected as input on the "items" attachment expected by the search page extension; -- **[4] `inputs`**: in this case is an empty object because this extension doesn't expect inputs; -- **[5] `output`**: Object representing the artifact produced by the `TechDocs` result item extension, on the example, it is a react component reference; -- **[6] `configSchema`**: represents the `TechDocs` search result item configuration definition, this is the same schema that adopters will use for customizing the extension via `app-config.yaml` file; -- **[7] `disable`**: Says that the result item extension will be enable by default when the `TechDocs` plugin is installed in the app. - -To complete the development cycle for creating a custom search result item extension, we should provide the extension via `TechDocs` plugin: - -```tsx -// plugins/techdocs/alpha.tsx -import { createPlugin } from "@backstage/frontend-plugin-api"; - -// plugins should be always exported as default -export default createPlugin({ - id: 'techdocs' - extensions: [TechDocsSearchResultItemExtension] -}) -``` - -Here is the `plugins/techdocs/alpha/index.tsx` final version, and you can also take a look at the [actual implementation](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/alpha/index.tsx) of a custom `TechDocs` search result item: - -```tsx -// plugins/techdocs/alpha.tsx -import { createPlugin } from '@backstage/frontend-plugin-api'; -import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha'; - -/** @alpha */ -export const TechDocsSearchResultListItemExtension = - createSearchResultListItemExtension({ - id: 'techdocs', - configSchema: createSchemaFromZod(z => - z.object({ - noTrack: z.boolean().default(false), - lineClamp: z.number().default(5), - }), - ), - predicate: result => result.type === 'techdocs', - component: async ({ config }) => { - const { TechDocsSearchResultListItem } = await import( - './components/TechDocsSearchResultListItem' - ); - return props => ; }, }); -/** @alpha */ -export default createPlugin({ - // plugins should be always exported as default +export default createFrontendPlugin({ id: 'techdocs', extensions: [TechDocsSearchResultListItemExtension], }); diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 14c31764ca..35bbd50489 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -275,18 +275,18 @@ 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'; + const navigationExtension = createExtension({ // ... namespace: 'app', name: 'nav', // [3]: Extension `id` will be `app/nav` following the extension naming pattern - config: { - schema: { - title: z => z.string().default('Sidebar Title'), - }, + configSchema: { + title: z.string().default('Sidebar Title'), }, factory({ config }) { return [ @@ -321,12 +321,12 @@ In all examples so far we have defined the extension factory as a regular functi For example, this is how we could define an extension where its output depends on the configuration: ```tsx +import { z } from 'zod'; + const exampleExtension = createExtension({ // ... - config: { - schema: { - disableIcon: z.boolean().default(false), - }, + configSchema: { + disableIcon: z.boolean().default(false), }, output: [coreExtensionData.reactElement, iconDataRef.optional()], *factory({ config }) { diff --git a/docs/frontend-system/architecture/23-extension-blueprints.md b/docs/frontend-system/architecture/23-extension-blueprints.md index 97eb01022d..e23f19eeb8 100644 --- a/docs/frontend-system/architecture/23-extension-blueprints.md +++ b/docs/frontend-system/architecture/23-extension-blueprints.md @@ -31,12 +31,12 @@ Every extension blueprint also provides a `makeWithOverrides` method. It is usef The following is an example of how one might use the blueprint `makeWithOverrides` method to create a new extension: ```tsx +import { z } from 'zod'; + const myPageExtension = PageBlueprint.makeWithOverrides({ // This defines additional configuration options for the extension. - config: { - schema: { - layout: z => z.enum(['grid', 'rows']).default('grid'), - }, + configSchema: { + layout: z.enum(['grid', 'rows']).default('grid'), }, // This defines additional inputs for the extension. inputs: { @@ -118,10 +118,8 @@ export interface MyWidgetBlueprintParams { export const MyWidgetBlueprint = createExtensionBlueprint({ kind: 'my-widget', attachTo: { id: 'page:my-plugin', input: 'widgets' }, - config: { - schema: { - title: z.string().optional(), - }, + configSchema: { + title: z.string().optional(), }, output: [coreExtensionData.reactElement], factory(params: MyWidgetBlueprintParams, { config }) { @@ -196,10 +194,8 @@ const widgetTitleRef = createExtensionDataRef().with({ export const MyWidgetBlueprint = createExtensionBlueprint({ kind: 'my-widget', attachTo: { id: 'page:my-plugin', input: 'widgets' }, - config: { - schema: { - title: z.string().optional(), - }, + configSchema: { + title: z.string().optional(), }, output: [widgetTitleRef, coreExtensionData.reactElement], factory(params: MyWidgetBlueprintParams, { config }) { diff --git a/docs/frontend-system/architecture/25-extension-overrides.md b/docs/frontend-system/architecture/25-extension-overrides.md index 22d553b084..f8221dd217 100644 --- a/docs/frontend-system/architecture/25-extension-overrides.md +++ b/docs/frontend-system/architecture/25-extension-overrides.md @@ -182,20 +182,18 @@ const myOverrideExtension = myExtension.override({ Overriding the configuration schema works very similarly to overriding the declared inputs. You can define new configuration fields that will be merged with the existing ones, but you can not re-declare existing fields. The following example shows how to override an extension and add a new configuration field: ```tsx +import { z } from 'zod'; + const exampleExtension = createExtension({ - config: { - schema: { - foo: z => z.string(), - }, + configSchema: { + foo: z.string(), }, // ... }); const overrideExtension = exampleExtension.override({ - config: { - schema: { - bar: z => z.string(), - }, + configSchema: { + bar: z.string(), }, factory(originalFactory, { config }) { // @@ -210,12 +208,12 @@ const overrideExtension = exampleExtension.override({ In all examples so far we have called the `originalFactory` callback without any arguments. It is however possible to override parts of the factory context for the original factory using the first parameter of the original factory. This can be useful if you want to override the provided configuration or change the inputs in some way. Note that if you are implementing a `factory` for a blueprint, the override factory context will instead be the second parameter of the original factory function. The following is an example of how to override the configuration for the original factory: ```tsx +import { z } from 'zod'; + const exampleExtension = createExtension({ name: 'example', - config: { - schema: { - layout: z => z.enum(['grid', 'list']).optional(), - }, + configSchema: { + layout: z.enum(['grid', 'list']).optional(), }, output: [coreExtensionData.reactElement], factory: ({ config }) => [ diff --git a/docs/frontend-system/architecture/60-migrations.md b/docs/frontend-system/architecture/60-migrations.md index aecc44b039..634eeb271e 100644 --- a/docs/frontend-system/architecture/60-migrations.md +++ b/docs/frontend-system/architecture/60-migrations.md @@ -11,6 +11,86 @@ This section provides migration guides for different versions of the frontend sy This guide is intended for app and plugin authors who have already migrated their code to the new frontend system, and are looking to keep it up to date with the latest changes. These guides do not cover trivial migrations that can be explained in a deprecation message, such as a renamed export. +## 1.50 + +### 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 with JSON Schema support, rather than requiring factory functions. The `createSchemaFromZod` helper has also been removed. + +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: + +```tsx +createExtension({ + // ... + config: { + schema: { + title: z => z.string().default('Hello'), + count: z => z.number().optional(), + }, + }, + factory({ config }) { + // ... + }, +}); +``` + +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({ + // ... + configSchema: { + title: z.string().default('Hello'), + count: z.number().optional(), + }, + factory({ config }) { + // ... + }, +}); +``` + +The same applies to `createExtensionBlueprint`: + +```tsx +import { z } from 'zod'; + +const MyBlueprint = createExtensionBlueprint({ + // ... + configSchema: { + title: z.string().default('Hello'), + }, + factory(params, { config }) { + // ... + }, +}); +``` + +And when adding config through `makeWithOverrides`: + +```tsx +import { z } from 'zod'; + +MyBlueprint.makeWithOverrides({ + configSchema: { + extra: z.string(), + }, + factory(originalFactory, { config }) { + return originalFactory({ + // ... + }); + }, +}); +``` + +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 ### `namespace` parameter should be removed diff --git a/docs/frontend-system/utility-apis/02-creating.md b/docs/frontend-system/utility-apis/02-creating.md index 2f6959e0ce..c6c6ce3785 100644 --- a/docs/frontend-system/utility-apis/02-creating.md +++ b/docs/frontend-system/utility-apis/02-creating.md @@ -94,11 +94,11 @@ The extension ID of the work API will be the kind `api:` followed by the plugin Here we will describe how to amend a utility API with the capability of having extension config, which is driven by [your app-config](../../conf/writing.md). You do this by giving an extension config schema to your API extension factory function. Let's refactor the example above to also accept configuration, which will require us to use the [override method of the blueprint](../architecture/23-extension-blueprints.md#creating-an-extension-from-a-blueprint-with-overrides). ```tsx title="in @internal/plugin-example" +import { z } from 'zod'; + const exampleWorkApi = ApiBlueprint.makeWithOverrides({ - config: { - schema: { - goSlow: z => z.boolean().default(false), - }, + configSchema: { + goSlow: z.boolean().default(false), }, factory(originalFactory, { config }) { return originalFactory({ 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 f5fc9f2e3a..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 { createSchemaFromZod } from '../../../frontend-plugin-api/src/schema/createSchemaFromZod'; +import { createDeprecatedConfigSchema } from '../../../frontend-plugin-api/src/schema/createPortableSchema'; import { TestApiRegistry, withLogCollector } from '@backstage/test-utils'; import { createErrorCollector } from '../wiring/createErrorCollector'; @@ -181,12 +181,10 @@ describe('instantiateAppNodeTree', () => { test: testDataRef, other: otherDataRef.optional(), }, - configSchema: createSchemaFromZod(z => - z.object({ - output: z.string().default('test'), - other: z.number().optional(), - }), - ), + configSchema: createDeprecatedConfigSchema({ + output: z => z.string().default('test'), + other: z => z.number().optional(), + }), factory({ config }) { return { test: config.output, other: config.other }; }, diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index 6b19998e06..90ad80f2e9 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -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" }, diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index c27ba1b814..9531ec82c9 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -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, + UNewOutput extends ExtensionDataRef, + UParentInputs extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + } = {}, + TNewExtensionConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, + >(args: { + name?: TName; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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>, + >( + 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())`' + : T['params'], + context?: { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + }, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; + }; + inputs: Expand>; + }, + ): Iterable & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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; + 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 + ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]> >; }; inputs: Expand>; @@ -230,7 +317,9 @@ export interface ExtensionBlueprint< ? {} : { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + 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, + UNewOutput extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + }, + TParamsInput extends AnyParamsInput_2>, + UParentInputs extends ExtensionDataRef, + TNewExtensionConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, + >( + args: Expand< + { + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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 + >, + >( + context?: Expand< + { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + } & ([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())`' + : Partial; + }) + >, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; + }; + inputs: Expand>; + }, + ): Iterable; + } & ([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())`' + : Partial; + }) + > & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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 + ReturnType< + ((...args: any[]) => any) & TExtensionConfigSchema[key] + > >; }; inputs: Expand>; @@ -560,14 +750,14 @@ export interface OverridableExtensionDefinition< inputs: T['inputs'] & TExtraInputs; config: T['config'] & { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + 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 = { }; // @public (undocumented) -export type PortableSchema = { +export type PortableSchema = { 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, diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 4b29101634..8649f8765b 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -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(): { }; // @public +export function createExtension< + UOutput extends ExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput; + }, + UFactoryOutput extends ExtensionDataValue, + 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 + : 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>; + [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; + [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, + TKind extends string, + UParentInputs extends ExtensionDataRef, + TDataRefs extends { + [name in string]: ExtensionDataRef; + } = never, + TNewConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, +>( + options: { + kind: TKind; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TInputs; + output: Array; + 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['T'] + : TParams, + context: { + node: AppNode; + apis: ApiHolder; + config: { + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; + }; + inputs: Expand>; + }, + ): Iterable; + dataRefs?: TDataRefs; + } & VerifyExtensionFactoryOutput, +): ExtensionBlueprint<{ + kind: TKind; + params: TParams; + output: UOutput extends ExtensionDataRef< + infer IData, + infer IId, + infer IConfig + > + ? ExtensionDataRef + : 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 & + VerifyExtensionAttachTo; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TInputs; + output: Array; + 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['T'] + : TParams, + context: { + node: AppNode; + apis: ApiHolder; + config: { + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; + }; + inputs: Expand>; + }, + ): Iterable; + dataRefs?: TDataRefs; + } & VerifyExtensionFactoryOutput, ): ExtensionBlueprint<{ kind: TKind; params: TParams; @@ -547,13 +698,17 @@ export function createExtensionBlueprint< config: string extends keyof TConfigSchema ? {} : { - [key in keyof TConfigSchema]: z.infer>; + [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; + [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 & @@ -583,6 +741,7 @@ export type CreateExtensionBlueprintOptions< if?: FilterPredicate; inputs?: TInputs; output: Array; + configSchema?: TNewConfigSchema; config?: { schema: TConfigSchema; }; @@ -597,7 +756,13 @@ export type CreateExtensionBlueprintOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TConfigSchema]: z.infer>; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; + } & { + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; }; inputs: Expand>; }, @@ -661,6 +826,9 @@ export type CreateExtensionOptions< }, UFactoryOutput extends ExtensionDataValue, 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; + configSchema?: TNewConfigSchema; config?: { schema: TConfigSchema; }; @@ -677,7 +846,13 @@ export type CreateExtensionOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TConfigSchema]: z.infer>; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; + } & { + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; }; inputs: Expand>; }): Iterable; @@ -1046,6 +1221,91 @@ export interface ExtensionBlueprint< inputs: T['inputs']; params: T['params']; }>; + makeWithOverrides< + TName extends string | undefined, + UFactoryOutput extends ExtensionDataValue, + UNewOutput extends ExtensionDataRef, + UParentInputs extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + } = {}, + TNewExtensionConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, + >(args: { + name?: TName; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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>, + >( + 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())`' + : T['params'], + context?: { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + }, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; + }; + inputs: Expand>; + }, + ): Iterable & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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; + 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 + ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]> >; }; inputs: Expand>; @@ -1116,7 +1377,9 @@ export interface ExtensionBlueprint< ? {} : { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + 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, + UNewOutput extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + }, + TParamsInput extends AnyParamsInput>, + UParentInputs extends ExtensionDataRef, + TNewExtensionConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, + >( + args: Expand< + { + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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 + >, + >( + context?: Expand< + { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + } & ([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())`' + : Partial; + }) + >, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; + }; + inputs: Expand>; + }, + ): Iterable; + } & ([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())`' + : Partial; + }) + > & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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 + ReturnType< + ((...args: any[]) => any) & TExtensionConfigSchema[key] + > >; }; inputs: Expand>; @@ -1776,14 +2140,14 @@ export interface OverridableExtensionDefinition< inputs: T['inputs'] & TExtraInputs; config: T['config'] & { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + 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 = { }; // @public (undocumented) -export type PortableSchema = { +export type PortableSchema = { 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; diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts index e822c13f88..d2118994fb 100644 --- a/packages/frontend-plugin-api/src/alpha.ts +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -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, diff --git a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts index b39abb175b..283ce66e8c 100644 --- a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts @@ -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], diff --git a/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx index b3be3fb8a8..4efe16df12 100644 --- a/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx @@ -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], diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx index 925c370af6..cc043e6283 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx @@ -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], diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts new file mode 100644 index 0000000000..c42298585b --- /dev/null +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts @@ -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'], + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts new file mode 100644 index 0000000000..a6bb3340d7 --- /dev/null +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -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 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 + */ +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 + extends PortableSchema { + /** @internal */ + readonly _fields: Record; +} + +/** + * Resolves each field, eagerly validates JSON Schema support, and returns + * a PortableSchema whose JSON Schema conversion is lazy. + * @internal + */ +export function createConfigSchema( + fields: Record, +): MergeablePortableSchema { + const resolved: Record = {}; + + 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: MergeablePortableSchema | undefined, + b: MergeablePortableSchema | undefined, +): MergeablePortableSchema | undefined { + if (!a && !b) { + return undefined; + } + if (!a) { + return b as MergeablePortableSchema; + } + if (!b) { + return a as MergeablePortableSchema; + } + + return buildPortableSchema({ + ...a._fields, + ...b._fields, + }); +} + +/** + * Assembles resolved fields into a PortableSchema with per-field + * validation (eager) and lazy JSON Schema generation. + */ +function buildPortableSchema( + fields: Record, +): MergeablePortableSchema { + 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; + const result: Record = {}; + 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 = { + 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; + 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, +): JsonObject { + const properties: Record = {}; + const required: string[] = []; + + for (const [key, field] of Object.entries(fields)) { + properties[key] = field.toJsonSchema(); + if (field.required) { + required.push(key); + } + } + + const schema: Record = { + 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; + unionErrors?: Array<{ issues: Array }>; +}): 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}`, + ); +} diff --git a/packages/frontend-plugin-api/src/schema/createSchemaFromZod.test.ts b/packages/frontend-plugin-api/src/schema/createSchemaFromZod.test.ts deleted file mode 100644 index 477a1a58c6..0000000000 --- a/packages/frontend-plugin-api/src/schema/createSchemaFromZod.test.ts +++ /dev/null @@ -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`); - }); -}); diff --git a/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts b/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts deleted file mode 100644 index 6fc7e3fd02..0000000000 --- a/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts +++ /dev/null @@ -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( - schemaCreator: (zImpl: typeof z) => TSchema, -): PortableSchema, z.input> { - 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; -} diff --git a/packages/frontend-plugin-api/src/schema/index.ts b/packages/frontend-plugin-api/src/schema/index.ts index a8f92a37f4..35d5465cbe 100644 --- a/packages/frontend-plugin-api/src/schema/index.ts +++ b/packages/frontend-plugin-api/src/schema/index.ts @@ -15,3 +15,4 @@ */ export { type PortableSchema } from './types'; +export { type StandardSchemaV1 } from './createPortableSchema'; diff --git a/packages/frontend-plugin-api/src/schema/types.ts b/packages/frontend-plugin-api/src/schema/types.ts index 5644068e90..54ad73ee96 100644 --- a/packages/frontend-plugin-api/src/schema/types.ts +++ b/packages/frontend-plugin-api/src/schema/types.ts @@ -17,7 +17,16 @@ import { JsonObject } from '@backstage/types'; /** @public */ -export type PortableSchema = { +export type PortableSchema = { 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 }; }; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 1b5869460a..6735fc9e5d 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -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, 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; + 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>; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; + } & { + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; }; inputs: Expand>; }): Iterable; @@ -237,6 +258,105 @@ export interface OverridableExtensionDefinition< >; }; + override< + UFactoryOutput extends ExtensionDataValue, + UNewOutput extends ExtensionDataRef, + TExtraInputs extends { [inputName in string]: ExtensionInput }, + TParamsInput extends AnyParamsInput>, + UParentInputs extends ExtensionDataRef, + TNewExtensionConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, + >( + args: Expand< + { + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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 + >, + >( + context?: Expand< + { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + } & ([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())`' + : Partial; + }) + >, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; + }; + inputs: Expand>; + }, + ): Iterable; + } & ([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())`' + : Partial; + }) + > & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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 + ReturnType< + ((...args: any[]) => any) & TExtensionConfigSchema[key] + > >; }; inputs: Expand>; @@ -324,14 +447,14 @@ export interface OverridableExtensionDefinition< inputs: T['inputs'] & TExtraInputs; config: T['config'] & { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + 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, + 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 + : 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>; + [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; + [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 { + 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 - >; - }; - configInput: string extends keyof TConfigSchema - ? {} - : z.input< - z.ZodObject<{ - [key in keyof TConfigSchema]: ReturnType; - }> - >; - 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 => { - return createExtensionDataContainer( + (innerContext: any): ExtensionDataContainer => { + return createExtensionDataContainer( options.factory({ node, apis, diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index 7f3ef5cb88..17405c8b9d 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -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(
{String(config.title)}
), + ]; + }, + }); + + 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(
)]; + }, + }); + + 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', diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index cd593f3c93..99ed7c1afb 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -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, TDataRefs extends { [name in string]: ExtensionDataRef }, UParentInputs extends ExtensionDataRef, + TNewConfigSchema extends { [key in string]: StandardSchemaV1 } = {}, > = { kind: TKind; attachTo: ExtensionDefinitionAttachTo & @@ -118,7 +122,14 @@ export type CreateExtensionBlueprintOptions< if?: FilterPredicate; inputs?: TInputs; output: Array; + 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>; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; + } & { + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; }; inputs: Expand>; }, @@ -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, + UNewOutput extends ExtensionDataRef, + UParentInputs extends ExtensionDataRef, + TExtraInputs extends { [inputName in string]: ExtensionInput } = {}, + TNewExtensionConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, + >(args: { + name?: TName; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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>, + >( + 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())`' + : T['params'], + context?: { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + }, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; + }; + inputs: Expand>; + }, + ): Iterable & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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; + 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; + 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 + ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]> >; }; inputs: Expand>; @@ -313,7 +417,9 @@ export interface ExtensionBlueprint< ? {} : { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + 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( * 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( * ``` * @public */ +export function createExtensionBlueprint< + TParams extends object | ExtensionBlueprintDefineParams, + UOutput extends ExtensionDataRef, + TInputs extends { [inputName in string]: ExtensionInput }, + UFactoryOutput extends ExtensionDataValue, + TKind extends string, + UParentInputs extends ExtensionDataRef, + TDataRefs extends { [name in string]: ExtensionDataRef } = never, + TNewConfigSchema extends { [key in string]: StandardSchemaV1 } = {}, +>( + options: { + kind: TKind; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TInputs; + output: Array; + 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['T'] + : TParams, + context: { + node: AppNode; + apis: ApiHolder; + config: { + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; + }; + inputs: Expand>; + }, + ): Iterable; + dataRefs?: TDataRefs; + } & VerifyExtensionFactoryOutput, +): ExtensionBlueprint<{ + kind: TKind; + params: TParams; + output: UOutput extends ExtensionDataRef< + infer IData, + infer IId, + infer IConfig + > + ? ExtensionDataRef + : 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 & + VerifyExtensionAttachTo; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TInputs; + output: Array; + 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['T'] + : TParams, + context: { + node: AppNode; + apis: ApiHolder; + config: { + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; + }; + inputs: Expand>; + }, + ): Iterable; + dataRefs?: TDataRefs; + } & VerifyExtensionFactoryOutput, ): 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> }; + : { + [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; + [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>, }) 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 - : never - >( + (innerParams: any, innerContext: any) => { + return createExtensionDataContainer( 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>; - }; - configInput: string extends keyof TConfigSchema - ? {} - : z.input< - z.ZodObject<{ - [key in keyof TConfigSchema]: ReturnType; - }> - >; - dataRefs: TDataRefs; - }>; + } as ExtensionBlueprint; } diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 3a96dd015f..22f0b5ee1c 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -343,9 +343,9 @@ const _default: OverridableFrontendPlugin< icon: string | undefined; }; configInput: { - filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; + filter?: FilterPredicate | undefined; group?: string | false | undefined; icon?: string | undefined; }; @@ -413,9 +413,9 @@ const _default: OverridableFrontendPlugin< icon: string | undefined; }; configInput: { - filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; + filter?: FilterPredicate | undefined; group?: string | false | undefined; icon?: string | undefined; }; diff --git a/plugins/app/src/extensions/AppLanguageApi.ts b/plugins/app/src/extensions/AppLanguageApi.ts index 80efa1d9e9..f9a512b483 100644 --- a/plugins/app/src/extensions/AppLanguageApi.ts +++ b/plugins/app/src/extensions/AppLanguageApi.ts @@ -18,14 +18,13 @@ import { AppLanguageSelector } from '../../../../packages/core-app-api/src/apis/implementations/AppLanguageApi'; import { appLanguageApiRef } from '@backstage/frontend-plugin-api'; import { ApiBlueprint } from '@backstage/frontend-plugin-api'; +import { z } from 'zod'; export const AppLanguageApi = ApiBlueprint.makeWithOverrides({ name: 'app-language', - config: { - schema: { - defaultLanguage: z => z.string().optional(), - availableLanguages: z => z.array(z.string()).optional(), - }, + configSchema: { + defaultLanguage: z.string().optional(), + availableLanguages: z.array(z.string()).optional(), }, factory(originalFactory, { config }) { return originalFactory(defineParams => diff --git a/plugins/catalog-graph/README-alpha.md b/plugins/catalog-graph/README-alpha.md index b7cd5f65be..f5500e5b3f 100644 --- a/plugins/catalog-graph/README-alpha.md +++ b/plugins/catalog-graph/README-alpha.md @@ -189,31 +189,20 @@ app: Overriding the card extension allows you to modify how it is implemented. -> [!CAUTION] -> To maintain the same level of configuration, you should define the same or an extended configuration schema. - Here is an example overriding the card extension with a custom component: ```tsx -import { createFrontendModule } from '@backstage/backstage-plugin-api'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha'; export default createFrontendModule({ pluginId: 'catalog-graph', extensions: [ - EntityCardBlueprint.makeWithOverrides({ - name: 'entity-relations', - config: { - schema: { - filter: z => z.string().optional(), - // Omitting the rest of default configs for simplicity in this example - }, - }, - factory(originalFactory, { config }) { - return originalFactory({ - loader: () => - import('./components').then(m => ), - }); + EntityCardBlueprint.make({ + name: 'relations', + params: { + loader: () => + import('./components').then(m => ), }, }), ], @@ -291,29 +280,29 @@ app: Overriding the page extension allows you to modify how it is implemented. > [!CAUTION] -> To maintain the same level of configuration, you need to define the same or an extended configuration schema. In order to avoid side effects on external plugins that expect this page to be associated with the default path and route reference, remember to use the same default path so that applications that use the default path still point to the same page and the same route reference. +> In order to avoid side effects on external plugins that expect this page to be associated with the default path and route reference, remember to use the same default path so that applications that use the default path still point to the same page and the same route reference. -Here is example overriding the page extension with a custom component: +Here is an example overriding the page extension with a custom component: ```tsx -import { createFrontendModule, createPageExtension, createSchemaFromZod } from '@backstage/backstage-plugin-api'; -import { convertLegacyRouteRef } from '@backstage/core-compat-api'; +import { + createFrontendModule, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; import { catalogGraphRouteRef } from '@backstage/plugin-catalog-graph'; export default createFrontendModule({ pluginId: 'catalog-graph', extensions: [ - createPageExtension({ - // Omitting name since it is an index page - path: '/catalog-graph', - routeRef: convertLegacyRouteRef(catalogGraphRouteRef), - createSchemaFromZod(z => z.object({ - path: z.string().default('/catalog-graph') - // Omitting the rest of default configs for simplicity in this example - })), - loader: () => import('./components').then(m => ) - }) - ] + PageBlueprint.make({ + params: { + path: '/catalog-graph', + routeRef: catalogGraphRouteRef, + loader: () => + import('./components').then(m => ), + }, + }), + ], }); ``` diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index a9756ffcff..af979717b1 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -87,7 +87,8 @@ "qs": "^6.9.4", "react-use": "^17.2.4", "yaml": "^2.0.0", - "zen-observable": "^0.10.0" + "zen-observable": "^0.10.0", + "zod": "^3.25.76 || ^4.0.0" }, "devDependencies": { "@backstage/cli": "workspace:^", @@ -105,8 +106,7 @@ "react": "^18.0.2", "react-dom": "^18.0.2", "react-router-dom": "^6.30.2", - "react-test-renderer": "^16.13.1", - "zod": "^3.25.76 || ^4.0.0" + "react-test-renderer": "^16.13.1" }, "peerDependencies": { "@backstage/frontend-test-utils": "workspace:^", diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 8cc0c5a84e..4eb7c7be59 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -339,9 +339,9 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ icon: string | undefined; }; configInput: { - filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; + filter?: FilterPredicate | undefined; group?: string | false | undefined; icon?: string | undefined; }; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index 491f3a2dba..776c297fe0 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -45,158 +45,19 @@ describe('EntityCardBlueprint', () => { "input": "cards", }, "configSchema": { - "parse": [Function], - "schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "filter": { - "anyOf": [ - { - "type": "string", - }, - { - "anyOf": [ - { - "anyOf": [ - { - "additionalProperties": { - "anyOf": [ - { - "type": [ - "string", - "number", - "boolean", - ], - }, - { - "additionalProperties": false, - "properties": { - "$exists": { - "type": "boolean", - }, - }, - "required": [ - "$exists", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$in": { - "items": { - "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0", - }, - "type": "array", - }, - }, - "required": [ - "$in", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$contains": { - "$ref": "#/properties/filter/anyOf/1", - }, - }, - "required": [ - "$contains", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$hasPrefix": { - "type": "string", - }, - }, - "required": [ - "$hasPrefix", - ], - "type": "object", - }, - ], - }, - "propertyNames": { - "pattern": "^(?!\\$).*$", - }, - "type": "object", - }, - { - "additionalProperties": { - "not": {}, - }, - "propertyNames": { - "pattern": "^\\$", - }, - "type": "object", - }, - ], - }, - { - "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0", - }, - { - "additionalProperties": false, - "properties": { - "$all": { - "items": { - "$ref": "#/properties/filter/anyOf/1", - }, - "type": "array", - }, - }, - "required": [ - "$all", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$any": { - "items": { - "$ref": "#/properties/filter/anyOf/1", - }, - "type": "array", - }, - }, - "required": [ - "$any", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$not": { - "$ref": "#/properties/filter/anyOf/1", - }, - }, - "required": [ - "$not", - ], - "type": "object", - }, - ], - }, - ], - }, - "type": { - "enum": [ - "info", - "content", - ], - "type": "string", - }, + "_fields": { + "filter": { + "required": false, + "toJsonSchema": [Function], + "validate": [Function], + }, + "type": { + "required": false, + "toJsonSchema": [Function], + "validate": [Function], }, - "type": "object", }, + "parse": [Function], }, "disabled": false, "factory": [Function], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 7717b6a1f9..e6d68a31be 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -28,10 +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/v4'; /** * @alpha @@ -51,12 +52,11 @@ export const EntityCardBlueprint = createExtensionBlueprint({ filterExpression: entityFilterExpressionDataRef, type: entityCardTypeDataRef, }, - config: { - schema: { - filter: z => - z.union([z.string(), createZodV3FilterPredicateSchema(z)]).optional(), - type: z => z.enum(entityCardTypes).optional(), - }, + configSchema: { + filter: z + .union([z.string(), createZodV4FilterPredicateSchema()]) + .optional(), + type: z.enum(entityCardTypes).optional(), }, *factory( { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index 5bc1aba2c2..ff330473bf 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -47,171 +47,34 @@ describe('EntityContentBlueprint', () => { "input": "contents", }, "configSchema": { - "parse": [Function], - "schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "filter": { - "anyOf": [ - { - "type": "string", - }, - { - "anyOf": [ - { - "anyOf": [ - { - "additionalProperties": { - "anyOf": [ - { - "type": [ - "string", - "number", - "boolean", - ], - }, - { - "additionalProperties": false, - "properties": { - "$exists": { - "type": "boolean", - }, - }, - "required": [ - "$exists", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$in": { - "items": { - "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0", - }, - "type": "array", - }, - }, - "required": [ - "$in", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$contains": { - "$ref": "#/properties/filter/anyOf/1", - }, - }, - "required": [ - "$contains", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$hasPrefix": { - "type": "string", - }, - }, - "required": [ - "$hasPrefix", - ], - "type": "object", - }, - ], - }, - "propertyNames": { - "pattern": "^(?!\\$).*$", - }, - "type": "object", - }, - { - "additionalProperties": { - "not": {}, - }, - "propertyNames": { - "pattern": "^\\$", - }, - "type": "object", - }, - ], - }, - { - "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0", - }, - { - "additionalProperties": false, - "properties": { - "$all": { - "items": { - "$ref": "#/properties/filter/anyOf/1", - }, - "type": "array", - }, - }, - "required": [ - "$all", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$any": { - "items": { - "$ref": "#/properties/filter/anyOf/1", - }, - "type": "array", - }, - }, - "required": [ - "$any", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$not": { - "$ref": "#/properties/filter/anyOf/1", - }, - }, - "required": [ - "$not", - ], - "type": "object", - }, - ], - }, - ], - }, - "group": { - "anyOf": [ - { - "const": false, - "type": "boolean", - }, - { - "type": "string", - }, - ], - }, - "icon": { - "type": "string", - }, - "path": { - "type": "string", - }, - "title": { - "type": "string", - }, + "_fields": { + "filter": { + "required": false, + "toJsonSchema": [Function], + "validate": [Function], + }, + "group": { + "required": false, + "toJsonSchema": [Function], + "validate": [Function], + }, + "icon": { + "required": false, + "toJsonSchema": [Function], + "validate": [Function], + }, + "path": { + "required": false, + "toJsonSchema": [Function], + "validate": [Function], + }, + "title": { + "required": false, + "toJsonSchema": [Function], + "validate": [Function], }, - "type": "object", }, + "parse": [Function], }, "disabled": false, "factory": [Function], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index 65896c7833..d534ee0f2b 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -30,11 +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/v4'; /** * @alpha @@ -60,15 +61,14 @@ export const EntityContentBlueprint = createExtensionBlueprint({ group: entityContentGroupDataRef, icon: entityContentIconDataRef, }, - config: { - schema: { - path: z => z.string().optional(), - title: z => z.string().optional(), - filter: z => - z.union([z.string(), createZodV3FilterPredicateSchema(z)]).optional(), - group: z => z.literal(false).or(z.string()).optional(), - icon: z => z.string().optional(), - }, + configSchema: { + path: z.string().optional(), + title: z.string().optional(), + filter: z + .union([z.string(), createZodV4FilterPredicateSchema()]) + .optional(), + group: z.literal(false).or(z.string()).optional(), + icon: z.string().optional(), }, *factory( params: { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 40f8915917..467068c668 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -63,144 +63,14 @@ describe('EntityContextMenuItemBlueprint', () => { "input": "contextMenuItems", }, "configSchema": { - "parse": [Function], - "schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "filter": { - "anyOf": [ - { - "anyOf": [ - { - "additionalProperties": { - "anyOf": [ - { - "type": [ - "string", - "number", - "boolean", - ], - }, - { - "additionalProperties": false, - "properties": { - "$exists": { - "type": "boolean", - }, - }, - "required": [ - "$exists", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$in": { - "items": { - "$ref": "#/properties/filter/anyOf/0/anyOf/0/additionalProperties/anyOf/0", - }, - "type": "array", - }, - }, - "required": [ - "$in", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$contains": { - "$ref": "#/properties/filter", - }, - }, - "required": [ - "$contains", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$hasPrefix": { - "type": "string", - }, - }, - "required": [ - "$hasPrefix", - ], - "type": "object", - }, - ], - }, - "propertyNames": { - "pattern": "^(?!\\$).*$", - }, - "type": "object", - }, - { - "additionalProperties": { - "not": {}, - }, - "propertyNames": { - "pattern": "^\\$", - }, - "type": "object", - }, - ], - }, - { - "$ref": "#/properties/filter/anyOf/0/anyOf/0/additionalProperties/anyOf/0", - }, - { - "additionalProperties": false, - "properties": { - "$all": { - "items": { - "$ref": "#/properties/filter", - }, - "type": "array", - }, - }, - "required": [ - "$all", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$any": { - "items": { - "$ref": "#/properties/filter", - }, - "type": "array", - }, - }, - "required": [ - "$any", - ], - "type": "object", - }, - { - "additionalProperties": false, - "properties": { - "$not": { - "$ref": "#/properties/filter", - }, - }, - "required": [ - "$not", - ], - "type": "object", - }, - ], - }, + "_fields": { + "filter": { + "required": false, + "toJsonSchema": [Function], + "validate": [Function], }, - "type": "object", }, + "parse": [Function], }, "disabled": false, "factory": [Function], diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index cac3bd8ff2..bb7349826b 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -808,9 +808,9 @@ const _default: OverridableFrontendPlugin< icon: string | undefined; }; configInput: { - filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; + filter?: FilterPredicate | undefined; group?: string | false | undefined; icon?: string | undefined; }; diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 136385e686..62b41f7466 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -101,9 +101,9 @@ const _default: OverridableFrontendPlugin< icon: string | undefined; }; configInput: { - filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; + filter?: FilterPredicate | undefined; group?: string | false | undefined; icon?: string | undefined; }; diff --git a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx index 3ef5e2d0e9..fe3fd29fc3 100644 --- a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx +++ b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx @@ -45,18 +45,14 @@ describe('SearchResultListItemBlueprint', () => { "input": "items", }, "configSchema": { - "parse": [Function], - "schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "noTrack": { - "default": false, - "type": "boolean", - }, + "_fields": { + "noTrack": { + "required": false, + "toJsonSchema": [Function], + "validate": [Function], }, - "type": "object", }, + "parse": [Function], }, "disabled": false, "factory": [Function], diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index afa6ce8de0..0a8e50c8e1 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -132,9 +132,9 @@ const _default: OverridableFrontendPlugin< icon: string | undefined; }; configInput: { - filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; + filter?: FilterPredicate | undefined; group?: string | false | undefined; icon?: string | undefined; }; diff --git a/yarn.lock b/yarn.lock index 49ec264e08..294105994d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3816,6 +3816,7 @@ __metadata: "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" + "@standard-schema/spec": "npm:^1.1.0" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0" "@types/react": "npm:^18.0.0" @@ -19105,7 +19106,7 @@ __metadata: languageName: node linkType: hard -"@standard-schema/spec@npm:^1.0.0": +"@standard-schema/spec@npm:^1.0.0, @standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" checksum: 10/a209615c9e8b2ea535d7db0a5f6aa0f962fd4ab73ee86a46c100fb78116964af1f55a27c1794d4801e534a196794223daa25ff5135021e03c7828aa3d95e1763