From 7169cd543c5f31ed0414cd4a74e45a1553f05ffa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 8 Apr 2026 11:18:58 +0200 Subject: [PATCH 01/24] exploration: Standard Schema utility for zod version decoupling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a mock/exploration file showing what a central Standard-Schema-based utility could look like for decoupling the exact zod version from the public API surface of extension config schemas. This is not wired into anything — just saving progress on the design exploration for per-field validation, JSON Schema generation, and schema merging across different sources (blueprint + override) that may use different schema libraries or zod versions. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/schema/createPortableSchema.ts | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 packages/frontend-plugin-api/src/schema/createPortableSchema.ts 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..877cbd2ef1 --- /dev/null +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -0,0 +1,454 @@ +/* + * 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. + */ + +// ----------------------------------------------------------------------- +// Mock / exploration file — not wired into anything. +// +// Shows what a central, reusable Standard-Schema-based utility could +// look like, covering: +// +// 1. Public types for APIs that accept per-field schema records +// 2. Type-level inference (output & input) without any zod types +// 3. Runtime validation with good error messages +// 4. JSON Schema generation from any supported source +// 5. Backward compat with the current (zImpl) => ZodType factory form +// 6. Merging schemas from different sources (blueprint + override) +// ----------------------------------------------------------------------- + +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'; + +// --------------------------------------------------------------------------- +// Standard Schema V1 — inlined from https://standardschema.dev +// +// This is the cross-library interface implemented by zod v3.25+, zod v4, +// valibot, arktype, and others. It's designed to be inlined: "Libraries +// wishing to implement the spec can copy/paste the code block below into +// their codebase" — no npm dependency required. +// +// See: https://github.com/standard-schema/standard-schema +// --------------------------------------------------------------------------- + +/** The Standard Schema interface. */ +interface StandardSchemaV1 { + readonly '~standard': StandardSchemaV1.Props; +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +namespace StandardSchemaV1 { + export interface Props { + readonly version: 1; + readonly vendor: string; + readonly validate: ( + value: unknown, + options?: Options | undefined, + ) => Result | Promise>; + readonly types?: Types | undefined; + } + + export type Result = SuccessResult | FailureResult; + + export interface SuccessResult { + readonly value: Output; + readonly issues?: undefined; + } + + export interface Options { + readonly libraryOptions?: Record | undefined; + } + + export interface FailureResult { + readonly issues: ReadonlyArray; + } + + export interface Issue { + readonly message: string; + readonly path?: ReadonlyArray | undefined; + } + + export interface PathSegment { + readonly key: PropertyKey; + } + + export interface Types { + readonly input: Input; + readonly output: Output; + } + + export type InferInput = NonNullable< + Schema['~standard']['types'] + >['input']; + + export type InferOutput = NonNullable< + Schema['~standard']['types'] + >['output']; +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * A single config field schema. Accepts any Standard Schema implementation, + * or the legacy factory form for backward compat and zod-based composition. + */ +type ConfigFieldSchema = StandardSchemaV1 | ((zImpl: typeof zodV3) => ZodType); + +/** A record of per-field config schemas. */ +type ConfigSchemaRecord = { [key: string]: ConfigFieldSchema }; + +/** Resolves a field entry to its StandardSchemaV1 form for type inference. */ +type ResolveField = T extends ( + ...args: any[] +) => infer R + ? R extends StandardSchemaV1 + ? R + : never + : T extends StandardSchemaV1 + ? T + : never; + +/** + * Infers the parsed output type of a config schema record. + * Replaces: `{ [key in keyof T]: z.infer> }` + */ +type InferConfigOutput = { + [K in keyof T]: StandardSchemaV1.InferOutput>; +}; + +/** + * Infers the raw input type (before defaults/transforms) of a config + * schema record. + * Replaces: `z.input>` + */ +type InferConfigInput = { + [K in keyof T]: StandardSchemaV1.InferInput>; +}; + +// --------------------------------------------------------------------------- +// The PortableSchema — now with per-field tracking for mergeability +// --------------------------------------------------------------------------- + +/** + * Internally, each PortableSchema tracks which keys it owns and how to + * validate each one individually. This is the unit of composition: when a + * blueprint defines config fields and an override adds more, each produces + * its own PortableSchema and they're merged at the end. + */ +interface FieldValidator { + validate(value: unknown): { value: unknown } | { errors: string[] }; + jsonSchema: JsonObject; + required: boolean; +} + +/** + * Internal representation that carries per-field validators alongside the + * public PortableSchema surface. The brand field is used to detect whether + * a PortableSchema came from this utility (and thus supports merging). + */ +interface MergeablePortableSchema + extends PortableSchema { + /** @internal */ + readonly _fields: Record; +} + +// --------------------------------------------------------------------------- +// createPortableSchema — builds from a field record +// --------------------------------------------------------------------------- + +function createPortableSchema( + fields: T, +): MergeablePortableSchema, InferConfigInput> { + const fieldValidators: Record = {}; + + for (const [key, field] of Object.entries(fields)) { + const resolved = typeof field === 'function' ? field(zodV3) : field; + fieldValidators[key] = buildFieldValidator(key, resolved); + } + + return buildPortableSchema(fieldValidators); +} + +// --------------------------------------------------------------------------- +// mergePortableSchemas — combines schemas from different sources +// +// This is the key operation for blueprint + override composition. Each +// source may use a completely different schema library. Because we track +// per-field validators, merging is just combining the field maps — +// no need to mix schema types within a single validator. +// --------------------------------------------------------------------------- + +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, + }); +} + +// --------------------------------------------------------------------------- +// buildPortableSchema — internal: produces a PortableSchema from a +// field validator map. This is the shared implementation for both +// createPortableSchema and mergePortableSchemas. +// --------------------------------------------------------------------------- + +function buildPortableSchema( + fieldValidators: Record, +): MergeablePortableSchema { + const jsonSchema = buildObjectJsonSchema(fieldValidators); + + return { + parse(input) { + const inputObj = (input ?? {}) as Record; + const result: Record = {}; + const errors: string[] = []; + + for (const [key, validator] of Object.entries(fieldValidators)) { + const validated = validator.validate(inputObj[key]); + if ('errors' in validated) { + errors.push(...validated.errors); + } else { + result[key] = validated.value; + } + } + + if (errors.length > 0) { + throw new Error(errors.join('; ')); + } + + return result as TOutput; + }, + + schema: jsonSchema, + + _fields: fieldValidators, + }; +} + +// --------------------------------------------------------------------------- +// buildFieldValidator — wraps a single schema (any type) into a +// normalized FieldValidator with validation, JSON Schema, and required. +// --------------------------------------------------------------------------- + +function buildFieldValidator(key: string, schema: unknown): FieldValidator { + if (isZodV3Type(schema)) { + return buildZodFieldValidator(key, schema); + } + if (isStandardSchema(schema)) { + return buildStandardFieldValidator(key, schema); + } + throw new Error( + `Config schema for field '${key}' is not a valid Standard Schema or zod schema`, + ); +} + +function buildZodFieldValidator(key: string, schema: ZodType): FieldValidator { + // Wrap the single field in a one-key z.object so we get proper zod + // object-level behavior (default application, optional handling, etc.) + const wrapper = zodV3.object({ [key]: schema }); + const wholeJsonSchema = zodToJsonSchema(wrapper) as Record; + + return { + validate(value) { + const result = wrapper.safeParse({ [key]: value }); + if (result.success) { + return { value: result.data[key] }; + } + return { errors: result.error.issues.map(formatZodIssue) }; + }, + jsonSchema: (wholeJsonSchema.properties?.[key] ?? {}) as JsonObject, + required: (wholeJsonSchema.required ?? []).includes(key), + }; +} + +function buildStandardFieldValidator( + key: string, + schema: StandardSchemaV1, +): FieldValidator { + let fieldJsonSchema: JsonObject; + if (hasJsonSchemaConverter(schema)) { + const raw = schema['~standard'].jsonSchema.input({ target: 'draft-07' }); + const { $schema: _, ...rest } = raw; + fieldJsonSchema = rest as JsonObject; + } else { + throw new Error( + `Config schema for field '${key}' does not support JSON Schema conversion`, + ); + } + + 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 }; + }, + jsonSchema: fieldJsonSchema, + required, + }; +} + +// --------------------------------------------------------------------------- +// buildObjectJsonSchema — assembles per-field JSON Schemas into a +// single object-level JSON Schema +// --------------------------------------------------------------------------- + +function buildObjectJsonSchema( + fieldValidators: Record, +): JsonObject { + const properties: Record = {}; + const required: string[] = []; + + for (const [key, validator] of Object.entries(fieldValidators)) { + properties[key] = validator.jsonSchema; + if (validator.required) { + required.push(key); + } + } + + const schema: Record = { + type: 'object', + properties, + additionalProperties: false, + }; + + if (required.length > 0) { + schema.required = required; + } + + return schema as JsonObject; +} + +// --------------------------------------------------------------------------- +// Detection helpers +// --------------------------------------------------------------------------- + +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; +} + +// --------------------------------------------------------------------------- +// Error formatting +// --------------------------------------------------------------------------- + +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}'`; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export { + createPortableSchema, + mergePortableSchemas, + type MergeablePortableSchema, + type ConfigFieldSchema, + type ConfigSchemaRecord, + type InferConfigOutput, + type InferConfigInput, + type StandardSchemaV1, +}; From 23a35d1b91d3232435bdc5f15353955d65e6456d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 9 Apr 2026 16:36:00 +0200 Subject: [PATCH 02/24] frontend-plugin-api: widen config schema constraint to accept Standard Schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens the TConfigSchema generic constraint in createExtension, createExtensionBlueprint, and their override/makeWithOverrides methods to accept both the existing zod factory form and direct Standard Schema instances via the new ConfigFieldSchema union type. Existing consumers are unaffected — the factory form continues to infer concrete types through z.infer>. Direct Standard Schema values are accepted but infer as any in the current iteration. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-plugin-api/report-alpha.api.md | 24 ++-- packages/frontend-plugin-api/report.api.md | 135 +++++++++++++++--- .../src/schema/createPortableSchema.ts | 111 +++++++++----- .../frontend-plugin-api/src/schema/index.ts | 5 + .../src/wiring/createExtension.ts | 41 ++++-- .../src/wiring/createExtensionBlueprint.ts | 37 +++-- 6 files changed, 262 insertions(+), 91 deletions(-) diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index c27ba1b814..82d9da2627 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -14,7 +14,8 @@ import { FilterPredicate } from '@backstage/filter-predicates'; import { JsonObject } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; import { ReactNode } from 'react'; -import type { z } from 'zod/v3'; +import { z } from 'zod/v3'; +import { ZodType } from 'zod/v3'; // @public export type AnyRouteRefParams = @@ -160,10 +161,11 @@ export interface ExtensionBlueprint< inputs: T['inputs']; params: T['params']; }>; + // Warning: (ae-forgotten-export) The symbol "ConfigFieldSchema" needs to be exported by the entry point alpha.d.ts makeWithOverrides< TName extends string | undefined, TExtensionConfigSchema extends { - [key in string]: (zImpl: typeof z) => z.ZodType; + [key in string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -212,7 +214,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 +232,9 @@ export interface ExtensionBlueprint< ? {} : { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + ReturnType< + ((...args: any[]) => any) & TExtensionConfigSchema[key] + > >; }) & T['config'] @@ -241,7 +245,7 @@ export interface ExtensionBlueprint< : z.input< z.ZodObject<{ [key in keyof TExtensionConfigSchema]: ReturnType< - TExtensionConfigSchema[key] + ((...args: any[]) => any) & TExtensionConfigSchema[key] >; }> >) & @@ -473,7 +477,7 @@ export interface OverridableExtensionDefinition< // (undocumented) override< TExtensionConfigSchema extends { - [key in string]: (zImpl: typeof z) => z.ZodType; + [key in string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -531,7 +535,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 +566,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] >; }> >; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 9752eaa6c8..54023e5b08 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -23,7 +23,8 @@ import { Observable } from '@backstage/types'; import { PropsWithChildren } from 'react'; import { ReactNode } from 'react'; import { SwappableComponentRef as SwappableComponentRef_2 } from '@backstage/frontend-plugin-api'; -import type { z } from 'zod/v3'; +import { z } from 'zod/v3'; +import { ZodType } from 'zod/v3'; // @public @deprecated export type AlertApi = { @@ -388,6 +389,16 @@ export const configApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; +// @public +export type ConfigFieldSchema = + | StandardSchemaV1 + | ((zImpl: typeof z) => ZodType); + +// @public +export type ConfigSchemaRecord = { + [key: string]: ConfigFieldSchema; +}; + // @public (undocumented) export interface ConfigurableExtensionDataRef< TData, @@ -464,7 +475,7 @@ export function createExtension< [inputName in string]: ExtensionInput; }, TConfigSchema extends { - [key: string]: (zImpl: typeof z) => z.ZodType; + [key: string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, const TKind extends string | undefined = undefined, @@ -484,13 +495,17 @@ export function createExtension< 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< @@ -514,7 +529,7 @@ export function createExtensionBlueprint< [inputName in string]: ExtensionInput; }, TConfigSchema extends { - [key in string]: (zImpl: typeof z) => z.ZodType; + [key in string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, TKind extends string, @@ -547,13 +562,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; @@ -568,7 +587,7 @@ export type CreateExtensionBlueprintOptions< [inputName in string]: ExtensionInput; }, TConfigSchema extends { - [key in string]: (zImpl: typeof z) => z.ZodType; + [key in string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, TDataRefs extends { @@ -597,7 +616,9 @@ export type CreateExtensionBlueprintOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TConfigSchema]: z.infer>; + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; }; inputs: Expand>; }, @@ -657,7 +678,7 @@ export type CreateExtensionOptions< [inputName in string]: ExtensionInput; }, TConfigSchema extends { - [key: string]: (zImpl: typeof z) => z.ZodType; + [key: string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, UParentInputs extends ExtensionDataRef, @@ -677,7 +698,9 @@ export type CreateExtensionOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TConfigSchema]: z.infer>; + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; }; inputs: Expand>; }): Iterable; @@ -1042,7 +1065,7 @@ export interface ExtensionBlueprint< makeWithOverrides< TName extends string | undefined, TExtensionConfigSchema extends { - [key in string]: (zImpl: typeof z) => z.ZodType; + [key in string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -1091,7 +1114,7 @@ export interface ExtensionBlueprint< apis: ApiHolder; config: T['config'] & { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]> >; }; inputs: Expand>; @@ -1109,7 +1132,9 @@ export interface ExtensionBlueprint< ? {} : { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + ReturnType< + ((...args: any[]) => any) & TExtensionConfigSchema[key] + > >; }) & T['config'] @@ -1120,7 +1145,7 @@ export interface ExtensionBlueprint< : z.input< z.ZodObject<{ [key in keyof TExtensionConfigSchema]: ReturnType< - TExtensionConfigSchema[key] + ((...args: any[]) => any) & TExtensionConfigSchema[key] >; }> >) & @@ -1682,7 +1707,7 @@ export interface OverridableExtensionDefinition< // (undocumented) override< TExtensionConfigSchema extends { - [key in string]: (zImpl: typeof z) => z.ZodType; + [key in string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -1740,7 +1765,9 @@ export interface OverridableExtensionDefinition< apis: ApiHolder; config: T['config'] & { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + ReturnType< + ((...args: any[]) => any) & TExtensionConfigSchema[key] + > >; }; inputs: Expand>; @@ -1769,14 +1796,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] >; }> >; @@ -2119,6 +2146,76 @@ export namespace SessionState { export type SignedOut = typeof SessionState.SignedOut; } +// @public +export interface StandardSchemaV1 { + // (undocumented) + readonly '~standard': StandardSchemaV1.Props; +} + +// @public (undocumented) +export namespace StandardSchemaV1 { + // (undocumented) + export interface FailureResult { + // (undocumented) + readonly issues: ReadonlyArray; + } + // (undocumented) + export type InferInput = NonNullable< + Schema['~standard']['types'] + >['input']; + // (undocumented) + export type InferOutput = NonNullable< + Schema['~standard']['types'] + >['output']; + // (undocumented) + export interface Issue { + // (undocumented) + readonly message: string; + // (undocumented) + readonly path?: ReadonlyArray | undefined; + } + // (undocumented) + export interface Options { + // (undocumented) + readonly libraryOptions?: Record | undefined; + } + // (undocumented) + export interface PathSegment { + // (undocumented) + readonly key: PropertyKey; + } + // (undocumented) + export interface Props { + // (undocumented) + readonly types?: Types | undefined; + // (undocumented) + readonly validate: ( + value: unknown, + options?: Options | undefined, + ) => Result | Promise>; + // (undocumented) + readonly vendor: string; + // (undocumented) + readonly version: 1; + } + // (undocumented) + export type Result = SuccessResult | FailureResult; + // (undocumented) + export interface SuccessResult { + // (undocumented) + readonly issues?: undefined; + // (undocumented) + readonly value: Output; + } + // (undocumented) + export interface Types { + // (undocumented) + readonly input: Input; + // (undocumented) + readonly output: Output; + } +} + // @public export interface StorageApi { forBucket(name: string): StorageApi; diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index 877cbd2ef1..6e83ceef97 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -44,13 +44,19 @@ import { PortableSchema } from './types'; // See: https://github.com/standard-schema/standard-schema // --------------------------------------------------------------------------- -/** The Standard Schema interface. */ -interface StandardSchemaV1 { +/** + * The Standard Schema interface. + * @public + */ +export interface StandardSchemaV1 { readonly '~standard': StandardSchemaV1.Props; } +/** + * @public + */ // eslint-disable-next-line @typescript-eslint/no-namespace -namespace StandardSchemaV1 { +export namespace StandardSchemaV1 { export interface Props { readonly version: 1; readonly vendor: string; @@ -106,38 +112,82 @@ namespace StandardSchemaV1 { /** * A single config field schema. Accepts any Standard Schema implementation, * or the legacy factory form for backward compat and zod-based composition. + * @public */ -type ConfigFieldSchema = StandardSchemaV1 | ((zImpl: typeof zodV3) => ZodType); +export type ConfigFieldSchema = + | StandardSchemaV1 + | ((zImpl: typeof zodV3) => ZodType); -/** A record of per-field config schemas. */ -type ConfigSchemaRecord = { [key: string]: ConfigFieldSchema }; - -/** Resolves a field entry to its StandardSchemaV1 form for type inference. */ -type ResolveField = T extends ( - ...args: any[] -) => infer R - ? R extends StandardSchemaV1 - ? R - : never - : T extends StandardSchemaV1 - ? T - : never; +/** + * A record of per-field config schemas. + * @public + */ +export type ConfigSchemaRecord = { [key: string]: ConfigFieldSchema }; /** * Infers the parsed output type of a config schema record. * Replaces: `{ [key in keyof T]: z.infer> }` + * + * Split into two mapped types joined by intersection to avoid conditional + * types in the value position, which TypeScript defers in declaration emit. + * Factory-form fields use `ReturnType<...>['_output']` (eagerly evaluated), + * Standard Schema fields use `['~standard']['types']['output']`. + * @ignore */ type InferConfigOutput = { - [K in keyof T]: StandardSchemaV1.InferOutput>; + [K in keyof T as T[K] extends (...args: any[]) => any + ? K + : never]: ReturnType any)>['_output']; +} & { + [K in keyof T as T[K] extends (...args: any[]) => any + ? never + : K]: NonNullable< + (T[K] & StandardSchemaV1)['~standard']['types'] + >['output']; }; /** * Infers the raw input type (before defaults/transforms) of a config * schema record. * Replaces: `z.input>` + * + * Fields whose input type includes `undefined` are made optional keys, + * matching the behavior of `z.input>` for ZodDefault + * and ZodOptional fields. + * @ignore */ -type InferConfigInput = { - [K in keyof T]: StandardSchemaV1.InferInput>; +type InferConfigInput = _RequiredInput & + _OptionalInput; + +type _FactoryInput any> = ReturnType['_input']; +type _SchemaInput = NonNullable< + T['~standard']['types'] +>['input']; + +/** @ignore */ +type _RequiredInput = { + [K in keyof T as T[K] extends (...args: any[]) => any + ? undefined extends _FactoryInput any)> + ? never + : K + : undefined extends _SchemaInput + ? never + : K]: T[K] extends (...args: any[]) => any + ? _FactoryInput any)> + : _SchemaInput; +}; + +/** @ignore */ +type _OptionalInput = { + [K in keyof T as T[K] extends (...args: any[]) => any + ? undefined extends _FactoryInput any)> + ? K + : never + : undefined extends _SchemaInput + ? K + : never]?: T[K] extends (...args: any[]) => any + ? _FactoryInput any)> + : _SchemaInput; }; // --------------------------------------------------------------------------- @@ -161,7 +211,7 @@ interface FieldValidator { * public PortableSchema surface. The brand field is used to detect whether * a PortableSchema came from this utility (and thus supports merging). */ -interface MergeablePortableSchema +export interface MergeablePortableSchema extends PortableSchema { /** @internal */ readonly _fields: Record; @@ -171,7 +221,7 @@ interface MergeablePortableSchema // createPortableSchema — builds from a field record // --------------------------------------------------------------------------- -function createPortableSchema( +export function createPortableSchema( fields: T, ): MergeablePortableSchema, InferConfigInput> { const fieldValidators: Record = {}; @@ -193,7 +243,7 @@ function createPortableSchema( // no need to mix schema types within a single validator. // --------------------------------------------------------------------------- -function mergePortableSchemas( +export function mergePortableSchemas( a: MergeablePortableSchema | undefined, b: MergeablePortableSchema | undefined, ): MergeablePortableSchema | undefined { @@ -437,18 +487,3 @@ function formatStandardIssue( : fieldKey; return `${message} at '${path}'`; } - -// --------------------------------------------------------------------------- -// Exports -// --------------------------------------------------------------------------- - -export { - createPortableSchema, - mergePortableSchemas, - type MergeablePortableSchema, - type ConfigFieldSchema, - type ConfigSchemaRecord, - type InferConfigOutput, - type InferConfigInput, - type StandardSchemaV1, -}; diff --git a/packages/frontend-plugin-api/src/schema/index.ts b/packages/frontend-plugin-api/src/schema/index.ts index a8f92a37f4..3297c09ce3 100644 --- a/packages/frontend-plugin-api/src/schema/index.ts +++ b/packages/frontend-plugin-api/src/schema/index.ts @@ -15,3 +15,8 @@ */ export { type PortableSchema } from './types'; +export { + type StandardSchemaV1, + type ConfigFieldSchema, + type ConfigSchemaRecord, +} from './createPortableSchema'; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 1b5869460a..735438cdd7 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -28,6 +28,7 @@ import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; import { ExtensionInput } from './createExtensionInput'; import type { z } from 'zod/v3'; import { createSchemaFromZod } from '../schema/createSchemaFromZod'; +import type { ConfigFieldSchema } from '../schema/createPortableSchema'; import { OpaqueExtensionDefinition } from '@internal/frontend'; import { ExtensionDataContainer } from './types'; import { @@ -166,7 +167,7 @@ export type CreateExtensionOptions< TName extends string | undefined, UOutput extends ExtensionDataRef, TInputs extends { [inputName in string]: ExtensionInput }, - TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, + TConfigSchema extends { [key: string]: ConfigFieldSchema }, UFactoryOutput extends ExtensionDataValue, UParentInputs extends ExtensionDataRef, > = { @@ -185,7 +186,9 @@ export type CreateExtensionOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TConfigSchema]: z.infer>; + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; }; inputs: Expand>; }): Iterable; @@ -239,7 +242,7 @@ export interface OverridableExtensionDefinition< override< TExtensionConfigSchema extends { - [key in string]: (zImpl: typeof z) => z.ZodType; + [key in string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -295,7 +298,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 +329,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] >; }> >; @@ -400,7 +405,7 @@ function bindInputs( export function createExtension< UOutput extends ExtensionDataRef, TInputs extends { [inputName in string]: ExtensionInput }, - TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, + TConfigSchema extends { [key: string]: ConfigFieldSchema }, UFactoryOutput extends ExtensionDataValue, const TKind extends string | undefined = undefined, const TName extends string | undefined = undefined, @@ -419,16 +424,19 @@ export function createExtension< 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, @@ -447,8 +455,11 @@ export function createExtension< createSchemaFromZod(innerZ => innerZ.object( Object.fromEntries( - Object.entries(schemaDeclaration).map(([k, v]) => [k, v(innerZ)]), - ), + Object.entries(schemaDeclaration).map(([k, v]) => [ + k, + typeof v === 'function' ? v(innerZ) : v, + ]), + ) as Record, ), ); @@ -458,14 +469,16 @@ export function createExtension< ? {} : { [key in keyof TConfigSchema]: z.infer< - ReturnType + 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; diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index cd593f3c93..80c78ea24a 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 { ConfigFieldSchema } from '../schema/createPortableSchema'; import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; import { createExtensionDataContainer } from '@internal/frontend'; import { @@ -106,7 +107,7 @@ export type CreateExtensionBlueprintOptions< TParams extends object | ExtensionBlueprintDefineParams, UOutput extends ExtensionDataRef, TInputs extends { [inputName in string]: ExtensionInput }, - TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, + TConfigSchema extends { [key in string]: ConfigFieldSchema }, UFactoryOutput extends ExtensionDataValue, TDataRefs extends { [name in string]: ExtensionDataRef }, UParentInputs extends ExtensionDataRef, @@ -170,7 +171,9 @@ export type CreateExtensionBlueprintOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TConfigSchema]: z.infer>; + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; }; inputs: Expand>; }, @@ -248,7 +251,7 @@ export interface ExtensionBlueprint< makeWithOverrides< TName extends string | undefined, TExtensionConfigSchema extends { - [key in string]: (zImpl: typeof z) => z.ZodType; + [key in string]: ConfigFieldSchema; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -295,7 +298,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 +316,9 @@ export interface ExtensionBlueprint< ? {} : { [key in keyof TExtensionConfigSchema]: z.infer< - ReturnType + ReturnType< + ((...args: any[]) => any) & TExtensionConfigSchema[key] + > >; }) & T['config'] @@ -324,7 +329,7 @@ export interface ExtensionBlueprint< : z.input< z.ZodObject<{ [key in keyof TExtensionConfigSchema]: ReturnType< - TExtensionConfigSchema[key] + ((...args: any[]) => any) & TExtensionConfigSchema[key] >; }> >) & @@ -461,7 +466,7 @@ export function createExtensionBlueprint< TParams extends object | ExtensionBlueprintDefineParams, UOutput extends ExtensionDataRef, TInputs extends { [inputName in string]: ExtensionInput }, - TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, + TConfigSchema extends { [key in string]: ConfigFieldSchema }, UFactoryOutput extends ExtensionDataValue, TKind extends string, UParentInputs extends ExtensionDataRef, @@ -491,12 +496,18 @@ 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; @@ -592,13 +603,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; From 787200224e98d640d057d5a80e7559b4159ca628 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 9 Apr 2026 18:45:20 +0200 Subject: [PATCH 03/24] frontend-plugin-api: use @standard-schema/spec dependency instead of inlining Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-plugin-api/package.json | 1 + .../frontend-plugin-api/report-alpha.api.md | 9 ++- packages/frontend-plugin-api/report.api.md | 71 +------------------ packages/frontend-plugin-api/src/alpha.ts | 6 +- .../src/schema/createPortableSchema.ts | 69 +----------------- yarn.lock | 3 +- 6 files changed, 20 insertions(+), 139 deletions(-) diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index daee6ddbbd..91dbaa553c 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 82d9da2627..4df3233871 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 { z } from 'zod/v3'; import { ZodType } from 'zod/v3'; @@ -85,6 +86,11 @@ export interface AppTree { readonly root: AppNode; } +// @public +export type ConfigFieldSchema = + | StandardSchemaV1 + | ((zImpl: typeof z) => ZodType); + // @public (undocumented) export interface ConfigurableExtensionDataRef< TData, @@ -161,7 +167,6 @@ export interface ExtensionBlueprint< inputs: T['inputs']; params: T['params']; }>; - // Warning: (ae-forgotten-export) The symbol "ConfigFieldSchema" needs to be exported by the entry point alpha.d.ts makeWithOverrides< TName extends string | undefined, TExtensionConfigSchema extends { @@ -650,6 +655,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 54023e5b08..b975da2e14 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 { z } from 'zod/v3'; import { ZodType } from 'zod/v3'; @@ -2146,75 +2147,7 @@ export namespace SessionState { export type SignedOut = typeof SessionState.SignedOut; } -// @public -export interface StandardSchemaV1 { - // (undocumented) - readonly '~standard': StandardSchemaV1.Props; -} - -// @public (undocumented) -export namespace StandardSchemaV1 { - // (undocumented) - export interface FailureResult { - // (undocumented) - readonly issues: ReadonlyArray; - } - // (undocumented) - export type InferInput = NonNullable< - Schema['~standard']['types'] - >['input']; - // (undocumented) - export type InferOutput = NonNullable< - Schema['~standard']['types'] - >['output']; - // (undocumented) - export interface Issue { - // (undocumented) - readonly message: string; - // (undocumented) - readonly path?: ReadonlyArray | undefined; - } - // (undocumented) - export interface Options { - // (undocumented) - readonly libraryOptions?: Record | undefined; - } - // (undocumented) - export interface PathSegment { - // (undocumented) - readonly key: PropertyKey; - } - // (undocumented) - export interface Props { - // (undocumented) - readonly types?: Types | undefined; - // (undocumented) - readonly validate: ( - value: unknown, - options?: Options | undefined, - ) => Result | Promise>; - // (undocumented) - readonly vendor: string; - // (undocumented) - readonly version: 1; - } - // (undocumented) - export type Result = SuccessResult | FailureResult; - // (undocumented) - export interface SuccessResult { - // (undocumented) - readonly issues?: undefined; - // (undocumented) - readonly value: Output; - } - // (undocumented) - export interface Types { - // (undocumented) - readonly input: Input; - // (undocumented) - readonly output: Output; - } -} +export { StandardSchemaV1 }; // @public export interface StorageApi { diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts index e822c13f88..08226a4013 100644 --- a/packages/frontend-plugin-api/src/alpha.ts +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -47,7 +47,11 @@ export type { AppNodeSpec, AppTree, } from './apis'; -export type { PortableSchema } from './schema'; +export type { + PortableSchema, + StandardSchemaV1, + ConfigFieldSchema, +} from './schema'; export type { AnyRouteRefParams, RouteRef, diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index 6e83ceef97..eab87ad562 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -33,77 +33,12 @@ import { z as zodV3, type ZodType } from 'zod/v3'; import zodToJsonSchema from 'zod-to-json-schema'; import { PortableSchema } from './types'; -// --------------------------------------------------------------------------- -// Standard Schema V1 — inlined from https://standardschema.dev -// -// This is the cross-library interface implemented by zod v3.25+, zod v4, -// valibot, arktype, and others. It's designed to be inlined: "Libraries -// wishing to implement the spec can copy/paste the code block below into -// their codebase" — no npm dependency required. -// -// See: https://github.com/standard-schema/standard-schema -// --------------------------------------------------------------------------- - /** * The Standard Schema interface. * @public */ -export interface StandardSchemaV1 { - readonly '~standard': StandardSchemaV1.Props; -} - -/** - * @public - */ -// eslint-disable-next-line @typescript-eslint/no-namespace -export namespace StandardSchemaV1 { - export interface Props { - readonly version: 1; - readonly vendor: string; - readonly validate: ( - value: unknown, - options?: Options | undefined, - ) => Result | Promise>; - readonly types?: Types | undefined; - } - - export type Result = SuccessResult | FailureResult; - - export interface SuccessResult { - readonly value: Output; - readonly issues?: undefined; - } - - export interface Options { - readonly libraryOptions?: Record | undefined; - } - - export interface FailureResult { - readonly issues: ReadonlyArray; - } - - export interface Issue { - readonly message: string; - readonly path?: ReadonlyArray | undefined; - } - - export interface PathSegment { - readonly key: PropertyKey; - } - - export interface Types { - readonly input: Input; - readonly output: Output; - } - - export type InferInput = NonNullable< - Schema['~standard']['types'] - >['input']; - - export type InferOutput = NonNullable< - Schema['~standard']['types'] - >['output']; -} +export type { StandardSchemaV1 } from '@standard-schema/spec'; +import { type StandardSchemaV1 } from '@standard-schema/spec'; // --------------------------------------------------------------------------- // Public types diff --git a/yarn.lock b/yarn.lock index 821675739b..53a41c163d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3779,6 +3779,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" @@ -19062,7 +19063,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 From 4c7905d22ca397cfbd4ab16cc47cabc69c4e7a9e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 08:48:25 +0200 Subject: [PATCH 04/24] frontend-plugin-api: add `configSchema` option and deprecate `config.schema` Introduce a new top-level `configSchema` option for `createExtension`, `createExtensionBlueprint`, `override`, and `makeWithOverrides` that accepts Standard Schema values directly. The old `config.schema` form is deprecated via function/method overloads so that the entire call site gets editor strikethrough when matched. Signed-off-by: Patrik Oldsberg Made-with: Cursor Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/app-example-plugin/report.api.md | 4 +- .../frontend-plugin-api/report-alpha.api.md | 55 ++- packages/frontend-plugin-api/report.api.md | 416 +++++++++++++++++- .../src/schema/createPortableSchema.ts | 21 +- .../src/wiring/createExtension.ts | 259 +++++++++-- .../wiring/createExtensionBlueprint.test.tsx | 6 +- .../src/wiring/createExtensionBlueprint.ts | 264 +++++++++-- 7 files changed, 907 insertions(+), 118 deletions(-) diff --git a/packages/app-example-plugin/report.api.md b/packages/app-example-plugin/report.api.md index 432174da68..2269e5c3b3 100644 --- a/packages/app-example-plugin/report.api.md +++ b/packages/app-example-plugin/report.api.md @@ -22,11 +22,11 @@ const examplePlugin: OverridableFrontendPlugin< 'page:example': OverridableExtensionDefinition<{ kind: 'page'; name: undefined; - config: { + config: {} & { path: string | undefined; title: string | undefined; }; - configInput: { + configInput: {} & { title?: string | undefined; path?: string | undefined; }; diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index 4df3233871..0ec2a76218 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -87,9 +87,10 @@ export interface AppTree { } // @public -export type ConfigFieldSchema = - | StandardSchemaV1 - | ((zImpl: typeof z) => ZodType); +export type ConfigFieldSchema = StandardSchemaV1 | ConfigFieldSchemaFactory; + +// @public @deprecated +export type ConfigFieldSchemaFactory = (zImpl: typeof z) => ZodType; // @public (undocumented) export interface ConfigurableExtensionDataRef< @@ -178,6 +179,9 @@ export interface ExtensionBlueprint< TExtraInputs extends { [inputName in string]: ExtensionInput; } = {}, + TNewExtensionConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, >(args: { name?: TName; attachTo?: ExtensionDefinitionAttachTo & @@ -194,6 +198,10 @@ export interface ExtensionBlueprint< string}' is already defined in parent definition`; }; output?: Array; + configSchema?: TNewExtensionConfigSchema & { + [KName in keyof T['config']]?: `Error: Config key '${KName & + string}' is already defined in parent schema`; + }; config?: { schema: TExtensionConfigSchema & { [KName in keyof T['config']]?: `Error: Config key '${KName & @@ -218,6 +226,10 @@ export interface ExtensionBlueprint< node: AppNode; apis: ApiHolder; config: T['config'] & { + [key in keyof TNewExtensionConfigSchema]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + } & { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]> >; @@ -233,7 +245,11 @@ export interface ExtensionBlueprint< >; }): OverridableExtensionDefinition<{ config: Expand< - (string extends keyof TExtensionConfigSchema + { + [key in keyof TNewExtensionConfigSchema]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + } & (string extends keyof TExtensionConfigSchema ? {} : { [key in keyof TExtensionConfigSchema]: z.infer< @@ -245,7 +261,11 @@ export interface ExtensionBlueprint< T['config'] >; configInput: Expand< - (string extends keyof TExtensionConfigSchema + { + [key in keyof TNewExtensionConfigSchema]?: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['input']; + } & (string extends keyof TExtensionConfigSchema ? {} : z.input< z.ZodObject<{ @@ -491,6 +511,9 @@ export interface OverridableExtensionDefinition< }, TParamsInput extends AnyParamsInput_2>, UParentInputs extends ExtensionDataRef, + TNewExtensionConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, >( args: Expand< { @@ -508,6 +531,10 @@ export interface OverridableExtensionDefinition< string}' is already defined in parent definition`; }; output?: Array; + configSchema?: TNewExtensionConfigSchema & { + [KName in keyof T['config']]?: `Error: Config key '${KName & + string}' is already defined in parent schema`; + }; config?: { schema: TExtensionConfigSchema & { [KName in keyof T['config']]?: `Error: Config key '${KName & @@ -539,6 +566,10 @@ export interface OverridableExtensionDefinition< node: AppNode; apis: ApiHolder; config: T['config'] & { + [key in keyof TNewExtensionConfigSchema]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + } & { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType< ((...args: any[]) => any) & TExtensionConfigSchema[key] @@ -570,12 +601,19 @@ export interface OverridableExtensionDefinition< output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; inputs: T['inputs'] & TExtraInputs; config: T['config'] & { + [key in keyof TNewExtensionConfigSchema]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + } & { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]> >; }; - configInput: T['configInput'] & - z.input< + configInput: T['configInput'] & { + [key in keyof TNewExtensionConfigSchema]?: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['input']; + } & z.input< z.ZodObject<{ [key in keyof TExtensionConfigSchema]: ReturnType< ((...args: any[]) => any) & TExtensionConfigSchema[key] @@ -669,5 +707,8 @@ export interface SubRouteRef< readonly T: TParams; } +// @public @deprecated +export function zodField(factory: (zImpl: typeof z) => T): T; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index b975da2e14..42156875e4 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -469,7 +469,69 @@ export function createApiRef(): { }; }; +// Warning: (ae-forgotten-export) The symbol "VerifyExtensionFactoryOutput" needs to be exported by the entry point index.d.ts +// // @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: { + kind?: TKind; + name?: TName; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TInputs; + output: Array; + config?: never; + configSchema?: TNewConfigSchema; + factory(context: { + node: AppNode; + apis: ApiHolder; + config: { + [key in keyof TNewConfigSchema]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + }; + inputs: Expand>; + }): Iterable; + } & VerifyExtensionFactoryOutput, +): OverridableExtensionDefinition<{ + config: { + [key in keyof TNewConfigSchema]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + }; + configInput: { + [key in keyof TNewConfigSchema]?: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['input']; + }; + 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 { @@ -483,15 +545,30 @@ export function createExtension< const TName extends string | undefined = undefined, UParentInputs extends ExtensionDataRef = ExtensionDataRef, >( - options: CreateExtensionOptions< - TKind, - TName, - UOutput, - TInputs, - TConfigSchema, - UFactoryOutput, - UParentInputs - >, + options: { + kind?: TKind; + name?: TName; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TInputs; + output: Array; + configSchema?: never; + config?: { + schema: TConfigSchema; + }; + factory(context: { + node: AppNode; + apis: ApiHolder; + config: { + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; + }; + inputs: Expand>; + }): Iterable; + } & VerifyExtensionFactoryOutput, ): OverridableExtensionDefinition<{ config: string extends keyof TConfigSchema ? {} @@ -522,7 +599,80 @@ export function createExtension< name: string | undefined extends TName ? undefined : TName; }>; +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The reference is ambiguous because "createExtension" has more than one declaration; you need to add a TSDoc member reference selector +// // @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]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + }; + 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]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + }; + configInput: { + [key in keyof TNewConfigSchema]?: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['input']; + }; + dataRefs: TDataRefs; +}>; + +// @public @deprecated (undocumented) export function createExtensionBlueprint< TParams extends object | ExtensionBlueprintDefineParams, UOutput extends ExtensionDataRef, @@ -539,16 +689,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; @@ -595,6 +767,9 @@ export type CreateExtensionBlueprintOptions< [name in string]: ExtensionDataRef; }, UParentInputs extends ExtensionDataRef, + TNewConfigSchema extends { + [key in string]: StandardSchemaV1; + } = {}, > = { kind: TKind; attachTo: ExtensionDefinitionAttachTo & @@ -603,6 +778,7 @@ export type CreateExtensionBlueprintOptions< if?: FilterPredicate; inputs?: TInputs; output: Array; + configSchema?: TNewConfigSchema; config?: { schema: TConfigSchema; }; @@ -617,6 +793,10 @@ export type CreateExtensionBlueprintOptions< node: AppNode; apis: ApiHolder; config: { + [key in keyof TNewConfigSchema]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + } & { [key in keyof TConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TConfigSchema[key]> >; @@ -683,6 +863,9 @@ export type CreateExtensionOptions< }, UFactoryOutput extends ExtensionDataValue, UParentInputs extends ExtensionDataRef, + TNewConfigSchema extends { + [key: string]: StandardSchemaV1; + } = {}, > = { kind?: TKind; name?: TName; @@ -692,6 +875,7 @@ export type CreateExtensionOptions< if?: FilterPredicate; inputs?: TInputs; output: Array; + configSchema?: TNewConfigSchema; config?: { schema: TConfigSchema; }; @@ -699,6 +883,10 @@ export type CreateExtensionOptions< node: AppNode; apis: ApiHolder; config: { + [key in keyof TNewConfigSchema]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + } & { [key in keyof TConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TConfigSchema[key]> >; @@ -1063,6 +1251,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]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + }; + inputs: Expand>; + }, + ): Iterable & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UFactoryOutput + >; + }): OverridableExtensionDefinition<{ + config: Expand< + { + [key in keyof TNewExtensionConfigSchema]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + } & T['config'] + >; + configInput: Expand< + { + [key in keyof TNewExtensionConfigSchema]?: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['input']; + } & 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 { @@ -1090,6 +1363,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 & @@ -1706,6 +1980,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]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + }; + 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]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + }; + configInput: T['configInput'] & { + [key in keyof TNewExtensionConfigSchema]?: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['input']; + }; + }>; + // @deprecated (undocumented) override< TExtensionConfigSchema extends { [key in string]: ConfigFieldSchema; @@ -1734,6 +2106,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 & @@ -2519,4 +2892,9 @@ export function withApis( (props: PropsWithChildren>): JSX_3.Element; displayName: string; }; + +// Warnings were encountered during analysis: +// +// src/wiring/createExtension.d.ts:280:5 - (ae-forgotten-export) The symbol "VerifyExtensionAttachTo" needs to be exported by the entry point index.d.ts +// src/wiring/createExtension.d.ts:293:9 - (ae-forgotten-export) The symbol "ResolvedExtensionInputs" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index eab87ad562..26d4835e0f 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -46,7 +46,7 @@ import { type StandardSchemaV1 } from '@standard-schema/spec'; /** * A single config field schema. Accepts any Standard Schema implementation, - * or the legacy factory form for backward compat and zod-based composition. + * or the legacy factory form for backward compat. * @public */ export type ConfigFieldSchema = @@ -422,3 +422,22 @@ function formatStandardIssue( : fieldKey; return `${message} at '${path}'`; } + +// --------------------------------------------------------------------------- +// Deprecation warning +// --------------------------------------------------------------------------- + +let hasWarnedConfigSchemaProp = false; + +/** @internal */ +export function warnConfigSchemaPropDeprecation() { + if (!hasWarnedConfigSchemaProp) { + hasWarnedConfigSchemaProp = true; + // 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 v3.25+ or v4.', + ); + } +} diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 735438cdd7..5cd8f987f8 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -29,6 +29,8 @@ import { ExtensionInput } from './createExtensionInput'; import type { z } from 'zod/v3'; import { createSchemaFromZod } from '../schema/createSchemaFromZod'; import type { ConfigFieldSchema } from '../schema/createPortableSchema'; +import { warnConfigSchemaPropDeprecation } from '../schema/createPortableSchema'; +import { type StandardSchemaV1 } from '@standard-schema/spec'; import { OpaqueExtensionDefinition } from '@internal/frontend'; import { ExtensionDataContainer } from './types'; import { @@ -170,6 +172,7 @@ export type CreateExtensionOptions< TConfigSchema extends { [key: string]: ConfigFieldSchema }, UFactoryOutput extends ExtensionDataValue, UParentInputs extends ExtensionDataRef, + TNewConfigSchema extends { [key: string]: StandardSchemaV1 } = {}, > = { kind?: TKind; name?: TName; @@ -179,13 +182,24 @@ 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 TNewConfigSchema]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + } & { [key in keyof TConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TConfigSchema[key]> >; @@ -240,6 +254,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]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + }; + 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]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + }; + configInput: T['configInput'] & { + [key in keyof TNewExtensionConfigSchema]?: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['input']; + }; + }>; + + /** + * @deprecated Use the `configSchema` option instead of `config.schema`. + */ override< TExtensionConfigSchema extends { [key in string]: ConfigFieldSchema; @@ -266,6 +379,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 & @@ -402,6 +516,65 @@ 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: { + kind?: TKind; + name?: TName; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TInputs; + output: Array; + config?: never; + configSchema?: TNewConfigSchema; + factory(context: { + node: AppNode; + apis: ApiHolder; + config: { + [key in keyof TNewConfigSchema]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + }; + inputs: Expand>; + }): Iterable; + } & VerifyExtensionFactoryOutput, +): OverridableExtensionDefinition<{ + config: { + [key in keyof TNewConfigSchema]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + }; + configInput: { + [key in keyof TNewConfigSchema]?: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['input']; + }; + 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 }, @@ -411,15 +584,30 @@ export function createExtension< const TName extends string | undefined = undefined, UParentInputs extends ExtensionDataRef = ExtensionDataRef, >( - options: CreateExtensionOptions< - TKind, - TName, - UOutput, - TInputs, - TConfigSchema, - UFactoryOutput, - UParentInputs - >, + options: { + kind?: TKind; + name?: TName; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TInputs; + output: Array; + configSchema?: never; + config?: { + schema: TConfigSchema; + }; + factory(context: { + node: AppNode; + apis: ApiHolder; + config: { + [key in keyof TConfigSchema]: z.infer< + ReturnType<((...args: any[]) => any) & TConfigSchema[key]> + >; + }; + inputs: Expand>; + }): Iterable; + } & VerifyExtensionFactoryOutput, ): OverridableExtensionDefinition<{ config: string extends keyof TConfigSchema ? {} @@ -448,9 +636,18 @@ 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 = +}>; + +/** @internal */ +export function createExtension( + options: any, +): OverridableExtensionDefinition { + const schemaDeclaration = + options.configSchema ?? (options.config?.schema as any); + if (options.config?.schema) { + warnConfigSchemaPropDeprecation(); + } + const resolvedConfigSchema = schemaDeclaration && createSchemaFromZod(innerZ => innerZ.object( @@ -464,28 +661,7 @@ export function createExtension< ); return OpaqueExtensionDefinition.createInstance('v2', { - T: undefined as unknown as { - config: string extends keyof TConfigSchema - ? {} - : { - [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< - ((...args: any[]) => any) & TConfigSchema[key] - >; - }> - >; - output: UOutput; - inputs: TInputs; - kind: string | undefined extends TKind ? undefined : TKind; - name: string | undefined extends TName ? undefined : TName; - }, + T: undefined as any, kind: options.kind, name: options.name, attachTo: options.attachTo, @@ -493,7 +669,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[] = []; @@ -540,7 +716,7 @@ 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', @@ -585,11 +761,16 @@ export function createExtension< output: (overrideOptions.output ?? options.output) as ExtensionDataRef[], config: - options.config || overrideOptions.config + options.config || + options.configSchema || + overrideOptions.config || + overrideOptions.configSchema ? { schema: { ...options.config?.schema, + ...options.configSchema, ...overrideOptions.config?.schema, + ...overrideOptions.configSchema, }, } : undefined, @@ -604,8 +785,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..a76fafac89 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -248,6 +248,7 @@ describe('createExtensionBlueprint', () => { }, }); + // @ts-expect-error: overlapping config key 'text' TestExtensionBlueprint.makeWithOverrides({ name: 'my-extension', params: { @@ -255,9 +256,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(), }, }, }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 80c78ea24a..9be7a51f9f 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -29,6 +29,7 @@ import { import type { z } from 'zod/v3'; import { ExtensionInput } from './createExtensionInput'; import type { ConfigFieldSchema } from '../schema/createPortableSchema'; +import { type StandardSchemaV1 } from '@standard-schema/spec'; import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; import { createExtensionDataContainer } from '@internal/frontend'; import { @@ -111,6 +112,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 & @@ -119,7 +121,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; }; /** @@ -171,6 +180,10 @@ export type CreateExtensionBlueprintOptions< node: AppNode; apis: ApiHolder; config: { + [key in keyof TNewConfigSchema]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + } & { [key in keyof TConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TConfigSchema[key]> >; @@ -248,6 +261,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]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + }; + inputs: Expand>; + }, + ): Iterable & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UFactoryOutput + >; + }): OverridableExtensionDefinition<{ + config: Expand< + { + [key in keyof TNewExtensionConfigSchema]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + } & T['config'] + >; + configInput: Expand< + { + [key in keyof TNewExtensionConfigSchema]?: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['input']; + } & 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 { @@ -273,6 +372,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 & @@ -462,6 +562,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]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + }; + 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]: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['output']; + }; + configInput: { + [key in keyof TNewConfigSchema]?: NonNullable< + TNewConfigSchema[key]['~standard']['types'] + >['input']; + }; + dataRefs: TDataRefs; +}>; + +/** + * @deprecated Use the top-level `configSchema` option instead of `config.schema`. + * @public + */ export function createExtensionBlueprint< TParams extends object | ExtensionBlueprintDefineParams, UOutput extends ExtensionDataRef, @@ -472,20 +640,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, @@ -511,7 +700,10 @@ export function createExtensionBlueprint< }> >; dataRefs: TDataRefs; -}> { +}>; + +/** @internal */ +export function createExtensionBlueprint(options: any): any { const defineParams = options.defineParams as | ExtensionBlueprintDefineParams | undefined; @@ -529,14 +721,15 @@ 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) { return createExtension({ kind: options.kind, name: args.name, @@ -555,19 +748,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), { @@ -595,27 +787,5 @@ export function createExtensionBlueprint< }, }) as OverridableExtensionDefinition; }, - } as ExtensionBlueprint<{ - kind: TKind; - params: TParams; - output: any; - inputs: string extends keyof TInputs ? {} : TInputs; - config: string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer< - ReturnType<((...args: any[]) => any) & TConfigSchema[key]> - >; - }; - configInput: string extends keyof TConfigSchema - ? {} - : z.input< - z.ZodObject<{ - [key in keyof TConfigSchema]: ReturnType< - ((...args: any[]) => any) & TConfigSchema[key] - >; - }> - >; - dataRefs: TDataRefs; - }>; + } as ExtensionBlueprint; } From 0d398f3271fdfb8ddb16a9947650f1658f14b82c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 09:21:40 +0200 Subject: [PATCH 05/24] frontend-plugin-api: remove `ConfigFieldSchema` and fix API report warnings Remove the `ConfigFieldSchema` and `ConfigSchemaRecord` types that are no longer needed now that `configSchema` and `config.schema` each accept only one form. The deprecated overloads now constrain directly to the factory signature `(zImpl: typeof z) => z.ZodType`. Also fix pre-existing API report warnings by promoting `ResolvedExtensionInputs`, `RequiredExtensionIds`, `VerifyExtensionFactoryOutput`, and `VerifyExtensionAttachTo` to `@public` and exporting them from both entry points. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/app-example-plugin/report.api.md | 4 +- .../frontend-plugin-api/report-alpha.api.md | 271 +++++++++++++++--- packages/frontend-plugin-api/report.api.md | 81 ++++-- packages/frontend-plugin-api/src/alpha.ts | 10 +- .../src/schema/createPortableSchema.ts | 89 +----- .../frontend-plugin-api/src/schema/index.ts | 6 +- .../src/wiring/createExtension.ts | 15 +- .../src/wiring/createExtensionBlueprint.ts | 9 +- .../frontend-plugin-api/src/wiring/index.ts | 4 + 9 files changed, 310 insertions(+), 179 deletions(-) diff --git a/packages/app-example-plugin/report.api.md b/packages/app-example-plugin/report.api.md index 2269e5c3b3..432174da68 100644 --- a/packages/app-example-plugin/report.api.md +++ b/packages/app-example-plugin/report.api.md @@ -22,11 +22,11 @@ const examplePlugin: OverridableFrontendPlugin< 'page:example': OverridableExtensionDefinition<{ kind: 'page'; name: undefined; - config: {} & { + config: { path: string | undefined; title: string | undefined; }; - configInput: {} & { + configInput: { title?: string | undefined; path?: string | undefined; }; diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index 0ec2a76218..acf1152d6c 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -15,8 +15,7 @@ import { JsonObject } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; import { ReactNode } from 'react'; import { StandardSchemaV1 } from '@standard-schema/spec'; -import { z } from 'zod/v3'; -import { ZodType } from 'zod/v3'; +import type { z } from 'zod/v3'; // @public export type AnyRouteRefParams = @@ -86,12 +85,6 @@ export interface AppTree { readonly root: AppNode; } -// @public -export type ConfigFieldSchema = StandardSchemaV1 | ConfigFieldSchemaFactory; - -// @public @deprecated -export type ConfigFieldSchemaFactory = (zImpl: typeof z) => ZodType; - // @public (undocumented) export interface ConfigurableExtensionDataRef< TData, @@ -170,9 +163,6 @@ export interface ExtensionBlueprint< }>; makeWithOverrides< TName extends string | undefined, - TExtensionConfigSchema extends { - [key in string]: ConfigFieldSchema; - }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, UParentInputs extends ExtensionDataRef, @@ -198,10 +188,92 @@ export interface ExtensionBlueprint< 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]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + }; + inputs: Expand>; + }, + ): Iterable & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UFactoryOutput + >; + }): OverridableExtensionDefinition<{ + config: Expand< + { + [key in keyof TNewExtensionConfigSchema]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + } & T['config'] + >; + configInput: Expand< + { + [key in keyof TNewExtensionConfigSchema]?: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['input']; + } & 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 { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + UFactoryOutput extends ExtensionDataValue, + UNewOutput extends ExtensionDataRef, + UParentInputs extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + } = {}, + >(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; + configSchema?: never; config?: { schema: TExtensionConfigSchema & { [KName in keyof T['config']]?: `Error: Config key '${KName & @@ -226,10 +298,6 @@ export interface ExtensionBlueprint< node: AppNode; apis: ApiHolder; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; - } & { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]> >; @@ -245,11 +313,7 @@ export interface ExtensionBlueprint< >; }): OverridableExtensionDefinition<{ config: Expand< - { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; - } & (string extends keyof TExtensionConfigSchema + (string extends keyof TExtensionConfigSchema ? {} : { [key in keyof TExtensionConfigSchema]: z.infer< @@ -261,11 +325,7 @@ export interface ExtensionBlueprint< T['config'] >; configInput: Expand< - { - [key in keyof TNewExtensionConfigSchema]?: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['input']; - } & (string extends keyof TExtensionConfigSchema + (string extends keyof TExtensionConfigSchema ? {} : z.input< z.ZodObject<{ @@ -501,9 +561,6 @@ export interface OverridableExtensionDefinition< }; // (undocumented) override< - TExtensionConfigSchema extends { - [key in string]: ConfigFieldSchema; - }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, TExtraInputs extends { @@ -531,10 +588,105 @@ export interface OverridableExtensionDefinition< 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]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + }; + 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]: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['output']; + }; + configInput: T['configInput'] & { + [key in keyof TNewExtensionConfigSchema]?: NonNullable< + TNewExtensionConfigSchema[key]['~standard']['types'] + >['input']; + }; + }>; + // @deprecated (undocumented) + override< + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + UFactoryOutput extends ExtensionDataValue, + UNewOutput extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + }, + TParamsInput extends AnyParamsInput_2>, + UParentInputs extends ExtensionDataRef, + >( + 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; + configSchema?: never; config?: { schema: TExtensionConfigSchema & { [KName in keyof T['config']]?: `Error: Config key '${KName & @@ -566,10 +718,6 @@ export interface OverridableExtensionDefinition< node: AppNode; apis: ApiHolder; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; - } & { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType< ((...args: any[]) => any) & TExtensionConfigSchema[key] @@ -601,19 +749,12 @@ export interface OverridableExtensionDefinition< output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; inputs: T['inputs'] & TExtraInputs; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; - } & { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TExtensionConfigSchema[key]> >; }; - configInput: T['configInput'] & { - [key in keyof TNewExtensionConfigSchema]?: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['input']; - } & z.input< + configInput: T['configInput'] & + z.input< z.ZodObject<{ [key in keyof TExtensionConfigSchema]: ReturnType< ((...args: any[]) => any) & TExtensionConfigSchema[key] @@ -683,6 +824,27 @@ export type PortableSchema = { schema: JsonObject; }; +// @public (undocumented) +export type RequiredExtensionIds = + UExtensionData extends any + ? UExtensionData['config']['optional'] extends true + ? never + : UExtensionData['id'] + : never; + +// @public +export type ResolvedExtensionInputs< + TInputs extends { + [name in string]: ExtensionInput; + }, +> = { + [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] + ? Array>> + : false extends TInputs[InputName]['config']['optional'] + ? Expand> + : Expand | undefined>; +}; + // @public export interface RouteRef< TParams extends AnyRouteRefParams = AnyRouteRefParams, @@ -707,8 +869,31 @@ export interface SubRouteRef< readonly T: TParams; } -// @public @deprecated -export function zodField(factory: (zImpl: typeof z) => T): T; +// @public (undocumented) +export type VerifyExtensionAttachTo< + UOutput extends ExtensionDataRef, + UParentInput extends ExtensionDataRef, +> = ExtensionDataRef extends UParentInput + ? {} + : [RequiredExtensionIds] extends [RequiredExtensionIds] + ? {} + : `Error: This parent extension input requires the following extension data, but it is not declared as guaranteed output of this extension: ${JoinStringUnion< + Exclude, RequiredExtensionIds> + >}`; + +// @public (undocumented) +export type VerifyExtensionFactoryOutput< + UDeclaredOutput extends ExtensionDataRef, + UFactoryOutput extends ExtensionDataValue, +> = [RequiredExtensionIds] extends [UFactoryOutput['id']] + ? [UFactoryOutput['id']] extends [UDeclaredOutput['id']] + ? {} + : `Error: The extension factory has undeclared output(s): ${JoinStringUnion< + Exclude + >}` + : `Error: The extension factory is missing the following output(s): ${JoinStringUnion< + Exclude, UFactoryOutput['id']> + >}`; // (No @packageDocumentation comment for this package) ``` diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 42156875e4..7a7bbe4ffc 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -24,8 +24,7 @@ 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 { z } from 'zod/v3'; -import { ZodType } from 'zod/v3'; +import type { z } from 'zod/v3'; // @public @deprecated export type AlertApi = { @@ -390,16 +389,6 @@ export const configApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; -// @public -export type ConfigFieldSchema = - | StandardSchemaV1 - | ((zImpl: typeof z) => ZodType); - -// @public -export type ConfigSchemaRecord = { - [key: string]: ConfigFieldSchema; -}; - // @public (undocumented) export interface ConfigurableExtensionDataRef< TData, @@ -469,8 +458,6 @@ export function createApiRef(): { }; }; -// Warning: (ae-forgotten-export) The symbol "VerifyExtensionFactoryOutput" needs to be exported by the entry point index.d.ts -// // @public export function createExtension< UOutput extends ExtensionDataRef, @@ -538,7 +525,7 @@ export function createExtension< [inputName in string]: ExtensionInput; }, TConfigSchema extends { - [key: string]: ConfigFieldSchema; + [key: string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, const TKind extends string | undefined = undefined, @@ -599,8 +586,6 @@ export function createExtension< name: string | undefined extends TName ? undefined : TName; }>; -// Warning: (ae-unresolved-link) The @link reference could not be resolved: The reference is ambiguous because "createExtension" has more than one declaration; you need to add a TSDoc member reference selector -// // @public export function createExtensionBlueprint< TParams extends object | ExtensionBlueprintDefineParams, @@ -680,7 +665,7 @@ export function createExtensionBlueprint< [inputName in string]: ExtensionInput; }, TConfigSchema extends { - [key in string]: ConfigFieldSchema; + [key in string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, TKind extends string, @@ -760,7 +745,7 @@ export type CreateExtensionBlueprintOptions< [inputName in string]: ExtensionInput; }, TConfigSchema extends { - [key in string]: ConfigFieldSchema; + [key in string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, TDataRefs extends { @@ -859,7 +844,7 @@ export type CreateExtensionOptions< [inputName in string]: ExtensionInput; }, TConfigSchema extends { - [key: string]: ConfigFieldSchema; + [key: string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, UParentInputs extends ExtensionDataRef, @@ -1339,7 +1324,7 @@ export interface ExtensionBlueprint< makeWithOverrides< TName extends string | undefined, TExtensionConfigSchema extends { - [key in string]: ConfigFieldSchema; + [key in string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -2080,7 +2065,7 @@ export interface OverridableExtensionDefinition< // @deprecated (undocumented) override< TExtensionConfigSchema extends { - [key in string]: ConfigFieldSchema; + [key in string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -2459,6 +2444,27 @@ export const Progress: { // @public (undocumented) export type ProgressProps = {}; +// @public (undocumented) +export type RequiredExtensionIds = + UExtensionData extends any + ? UExtensionData['config']['optional'] extends true + ? never + : UExtensionData['id'] + : never; + +// @public +export type ResolvedExtensionInputs< + TInputs extends { + [name in string]: ExtensionInput; + }, +> = { + [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] + ? Array>> + : false extends TInputs[InputName]['config']['optional'] + ? Expand> + : Expand | undefined>; +}; + // @public export type RouteFunc = ( ...input: TParams extends undefined ? readonly [] : readonly [params: TParams] @@ -2871,6 +2877,32 @@ export const useTranslationRef: ( t: TranslationFunction; }; +// @public (undocumented) +export type VerifyExtensionAttachTo< + UOutput extends ExtensionDataRef, + UParentInput extends ExtensionDataRef, +> = ExtensionDataRef extends UParentInput + ? {} + : [RequiredExtensionIds] extends [RequiredExtensionIds] + ? {} + : `Error: This parent extension input requires the following extension data, but it is not declared as guaranteed output of this extension: ${JoinStringUnion< + Exclude, RequiredExtensionIds> + >}`; + +// @public (undocumented) +export type VerifyExtensionFactoryOutput< + UDeclaredOutput extends ExtensionDataRef, + UFactoryOutput extends ExtensionDataValue, +> = [RequiredExtensionIds] extends [UFactoryOutput['id']] + ? [UFactoryOutput['id']] extends [UDeclaredOutput['id']] + ? {} + : `Error: The extension factory has undeclared output(s): ${JoinStringUnion< + Exclude + >}` + : `Error: The extension factory is missing the following output(s): ${JoinStringUnion< + Exclude, UFactoryOutput['id']> + >}`; + // @public export const vmwareCloudAuthApiRef: ApiRef_2< OAuthApi & @@ -2892,9 +2924,4 @@ export function withApis( (props: PropsWithChildren>): JSX_3.Element; displayName: string; }; - -// Warnings were encountered during analysis: -// -// src/wiring/createExtension.d.ts:280:5 - (ae-forgotten-export) The symbol "VerifyExtensionAttachTo" needs to be exported by the entry point index.d.ts -// src/wiring/createExtension.d.ts:293:9 - (ae-forgotten-export) The symbol "ResolvedExtensionInputs" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts index 08226a4013..f622efe4f2 100644 --- a/packages/frontend-plugin-api/src/alpha.ts +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -37,6 +37,10 @@ export type { ExtensionInput, FrontendPlugin, OverridableExtensionDefinition, + ResolvedExtensionInputs, + RequiredExtensionIds, + VerifyExtensionFactoryOutput, + VerifyExtensionAttachTo, } from './wiring'; export type { ApiHolder, @@ -47,11 +51,7 @@ export type { AppNodeSpec, AppTree, } from './apis'; -export type { - PortableSchema, - StandardSchemaV1, - ConfigFieldSchema, -} from './schema'; +export type { PortableSchema, StandardSchemaV1 } from './schema'; export type { AnyRouteRefParams, RouteRef, diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index 26d4835e0f..1f99c66a5a 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -41,89 +41,10 @@ export type { StandardSchemaV1 } from '@standard-schema/spec'; import { type StandardSchemaV1 } from '@standard-schema/spec'; // --------------------------------------------------------------------------- -// Public types +// Internal type for the field record accepted by createPortableSchema // --------------------------------------------------------------------------- -/** - * A single config field schema. Accepts any Standard Schema implementation, - * or the legacy factory form for backward compat. - * @public - */ -export type ConfigFieldSchema = - | StandardSchemaV1 - | ((zImpl: typeof zodV3) => ZodType); - -/** - * A record of per-field config schemas. - * @public - */ -export type ConfigSchemaRecord = { [key: string]: ConfigFieldSchema }; - -/** - * Infers the parsed output type of a config schema record. - * Replaces: `{ [key in keyof T]: z.infer> }` - * - * Split into two mapped types joined by intersection to avoid conditional - * types in the value position, which TypeScript defers in declaration emit. - * Factory-form fields use `ReturnType<...>['_output']` (eagerly evaluated), - * Standard Schema fields use `['~standard']['types']['output']`. - * @ignore - */ -type InferConfigOutput = { - [K in keyof T as T[K] extends (...args: any[]) => any - ? K - : never]: ReturnType any)>['_output']; -} & { - [K in keyof T as T[K] extends (...args: any[]) => any - ? never - : K]: NonNullable< - (T[K] & StandardSchemaV1)['~standard']['types'] - >['output']; -}; - -/** - * Infers the raw input type (before defaults/transforms) of a config - * schema record. - * Replaces: `z.input>` - * - * Fields whose input type includes `undefined` are made optional keys, - * matching the behavior of `z.input>` for ZodDefault - * and ZodOptional fields. - * @ignore - */ -type InferConfigInput = _RequiredInput & - _OptionalInput; - -type _FactoryInput any> = ReturnType['_input']; -type _SchemaInput = NonNullable< - T['~standard']['types'] ->['input']; - -/** @ignore */ -type _RequiredInput = { - [K in keyof T as T[K] extends (...args: any[]) => any - ? undefined extends _FactoryInput any)> - ? never - : K - : undefined extends _SchemaInput - ? never - : K]: T[K] extends (...args: any[]) => any - ? _FactoryInput any)> - : _SchemaInput; -}; - -/** @ignore */ -type _OptionalInput = { - [K in keyof T as T[K] extends (...args: any[]) => any - ? undefined extends _FactoryInput any)> - ? K - : never - : undefined extends _SchemaInput - ? K - : never]?: T[K] extends (...args: any[]) => any - ? _FactoryInput any)> - : _SchemaInput; -}; +type FieldSchema = StandardSchemaV1 | ((zImpl: typeof zodV3) => ZodType); // --------------------------------------------------------------------------- // The PortableSchema — now with per-field tracking for mergeability @@ -156,9 +77,9 @@ export interface MergeablePortableSchema // createPortableSchema — builds from a field record // --------------------------------------------------------------------------- -export function createPortableSchema( - fields: T, -): MergeablePortableSchema, InferConfigInput> { +export function createPortableSchema( + fields: Record, +): MergeablePortableSchema { const fieldValidators: Record = {}; for (const [key, field] of Object.entries(fields)) { diff --git a/packages/frontend-plugin-api/src/schema/index.ts b/packages/frontend-plugin-api/src/schema/index.ts index 3297c09ce3..35d5465cbe 100644 --- a/packages/frontend-plugin-api/src/schema/index.ts +++ b/packages/frontend-plugin-api/src/schema/index.ts @@ -15,8 +15,4 @@ */ export { type PortableSchema } from './types'; -export { - type StandardSchemaV1, - type ConfigFieldSchema, - type ConfigSchemaRecord, -} from './createPortableSchema'; +export { type StandardSchemaV1 } from './createPortableSchema'; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 5cd8f987f8..dc30c25b49 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -28,7 +28,6 @@ import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; import { ExtensionInput } from './createExtensionInput'; import type { z } from 'zod/v3'; import { createSchemaFromZod } from '../schema/createSchemaFromZod'; -import type { ConfigFieldSchema } from '../schema/createPortableSchema'; import { warnConfigSchemaPropDeprecation } from '../schema/createPortableSchema'; import { type StandardSchemaV1 } from '@standard-schema/spec'; import { OpaqueExtensionDefinition } from '@internal/frontend'; @@ -58,7 +57,7 @@ type ResolvedExtensionInput = /** * Converts an extension input map into a matching collection of resolved inputs. * - * @ignore + * @public */ export type ResolvedExtensionInputs< TInputs extends { @@ -95,7 +94,7 @@ type JoinStringUnion< : JoinStringUnion : TResult; -/** @ignore */ +/** @public */ export type RequiredExtensionIds = UExtensionData extends any ? UExtensionData['config']['optional'] extends true @@ -103,7 +102,7 @@ export type RequiredExtensionIds = : UExtensionData['id'] : never; -/** @ignore */ +/** @public */ export type VerifyExtensionFactoryOutput< UDeclaredOutput extends ExtensionDataRef, UFactoryOutput extends ExtensionDataValue, @@ -117,7 +116,7 @@ export type VerifyExtensionFactoryOutput< Exclude, UFactoryOutput['id']> >}`; -/** @ignore */ +/** @public */ export type VerifyExtensionAttachTo< UOutput extends ExtensionDataRef, UParentInput extends ExtensionDataRef, @@ -169,7 +168,7 @@ export type CreateExtensionOptions< TName extends string | undefined, UOutput extends ExtensionDataRef, TInputs extends { [inputName in string]: ExtensionInput }, - TConfigSchema extends { [key: string]: ConfigFieldSchema }, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, UParentInputs extends ExtensionDataRef, TNewConfigSchema extends { [key: string]: StandardSchemaV1 } = {}, @@ -355,7 +354,7 @@ export interface OverridableExtensionDefinition< */ override< TExtensionConfigSchema extends { - [key in string]: ConfigFieldSchema; + [key in string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -578,7 +577,7 @@ export function createExtension< export function createExtension< UOutput extends ExtensionDataRef, TInputs extends { [inputName in string]: ExtensionInput }, - TConfigSchema extends { [key: string]: ConfigFieldSchema }, + TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, const TKind extends string | undefined = undefined, const TName extends string | undefined = undefined, diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 9be7a51f9f..bbd8ceeec3 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -28,7 +28,6 @@ import { } from './createExtension'; import type { z } from 'zod/v3'; import { ExtensionInput } from './createExtensionInput'; -import type { ConfigFieldSchema } from '../schema/createPortableSchema'; import { type StandardSchemaV1 } from '@standard-schema/spec'; import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; import { createExtensionDataContainer } from '@internal/frontend'; @@ -108,7 +107,7 @@ export type CreateExtensionBlueprintOptions< TParams extends object | ExtensionBlueprintDefineParams, UOutput extends ExtensionDataRef, TInputs extends { [inputName in string]: ExtensionInput }, - TConfigSchema extends { [key in string]: ConfigFieldSchema }, + TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, TDataRefs extends { [name in string]: ExtensionDataRef }, UParentInputs extends ExtensionDataRef, @@ -350,7 +349,7 @@ export interface ExtensionBlueprint< makeWithOverrides< TName extends string | undefined, TExtensionConfigSchema extends { - [key in string]: ConfigFieldSchema; + [key in string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, @@ -524,7 +523,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. @@ -634,7 +633,7 @@ export function createExtensionBlueprint< TParams extends object | ExtensionBlueprintDefineParams, UOutput extends ExtensionDataRef, TInputs extends { [inputName in string]: ExtensionInput }, - TConfigSchema extends { [key in string]: ConfigFieldSchema }, + TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, TKind extends string, UParentInputs extends ExtensionDataRef, diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index f3a39cc60d..676f5982ec 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -22,6 +22,10 @@ export { type ExtensionDefinitionParameters, type CreateExtensionOptions, type OverridableExtensionDefinition, + type ResolvedExtensionInputs, + type RequiredExtensionIds, + type VerifyExtensionFactoryOutput, + type VerifyExtensionAttachTo, } from './createExtension'; export { createExtensionInput, From 1f80e1a5a4b47bd7ebe19ad15394f6fda4df25f5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 10:13:19 +0200 Subject: [PATCH 06/24] frontend-plugin-api: per-field config schema with lazy JSON Schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the monolithic `createSchemaFromZod` approach with per-field schema resolution via `createConfigSchema`. Each field is resolved individually and eagerly validated for JSON Schema conversion support, but the actual JSON Schema generation is deferred until first access. `PortableSchema.schema` is now a lazy callable — it can still be accessed as a property (backward compat, deprecated) or called as a method returning `{ schema: JsonObject }` for the new API. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/tree/instantiateAppNodeTree.test.ts | 12 +- .../frontend-plugin-api/report-alpha.api.md | 9 +- packages/frontend-plugin-api/report.api.md | 9 +- .../src/blueprints/ApiBlueprint.test.ts | 17 +- .../src/blueprints/NavItemBlueprint.test.tsx | 16 +- .../src/blueprints/PageBlueprint.test.tsx | 24 +- .../src/schema/createPortableSchema.test.ts | 115 ++++++++++ .../src/schema/createPortableSchema.ts | 205 +++++++++--------- .../src/schema/createSchemaFromZod.ts | 38 +++- .../frontend-plugin-api/src/schema/types.ts | 13 +- .../src/wiring/createExtension.ts | 18 +- 11 files changed, 309 insertions(+), 167 deletions(-) create mode 100644 packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index f5fc9f2e3a..e8aca98db8 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 { createConfigSchema } 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: createConfigSchema({ + 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/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index acf1152d6c..bf71434082 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -819,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 (undocumented) diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 7a7bbe4ffc..49e4d24bd7 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -2418,9 +2418,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 diff --git a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts index b39abb175b..fabc15b3f4 100644 --- a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts @@ -182,18 +182,15 @@ 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], + "schema": [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..ccdaa53a55 100644 --- a/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx @@ -39,17 +39,15 @@ 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], + "schema": [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..9ac58b3b06 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx @@ -48,20 +48,20 @@ 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], + "schema": [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..0284b99b9a --- /dev/null +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts @@ -0,0 +1,115 @@ +/* + * 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 { + createConfigSchema, + mergePortableSchemas, +} from './createPortableSchema'; + +describe('createConfigSchema', () => { + it('should provide nice parse errors', () => { + const schema = createConfigSchema({ + foo: z => z.union([z.string(), z.number()]), + derp: z => z.object({ bar: z.number() }), + }); + + expect(() => { + return schema.parse({ derp: { bar: 'derp' } }); + }).toThrow( + `Missing required value at 'foo'; Expected number, received string at 'derp.bar'`, + ); + expect(() => { + return schema.parse(undefined); + }).toThrow(`Missing required value at 'foo'`); + }); + + it('should parse valid config', () => { + const schema = createConfigSchema({ + name: z => z.string(), + count: z => z.number().default(0), + }); + + expect(schema.parse({ name: 'hello' })).toEqual({ + name: 'hello', + count: 0, + }); + expect(schema.parse({ name: 'hi', count: 5 })).toEqual({ + name: 'hi', + count: 5, + }); + }); + + it('should generate JSON Schema lazily via schema()', () => { + const schema = createConfigSchema({ + 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 support backward-compatible property access on schema', () => { + const schema = createConfigSchema({ + title: z => z.string(), + }); + + expect(schema.schema.type).toBe('object'); + expect(schema.schema.properties).toBeDefined(); + }); + + it('should support merging schemas', () => { + const a = createConfigSchema({ + name: z => z.string(), + }); + const b = createConfigSchema({ + count: z => z.number().default(0), + }); + + const merged = mergePortableSchemas(a, b)!; + expect(merged.parse({ name: 'hello' })).toEqual({ + name: 'hello', + count: 0, + }); + + const result = merged.schema(); + expect(result.schema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['name'], + }); + }); + + it('should handle merge with undefined', () => { + const a = createConfigSchema({ name: z => z.string() }); + + expect(mergePortableSchemas(a, undefined)).toBe(a); + expect(mergePortableSchemas(undefined, a)).toBe(a); + expect(mergePortableSchemas(undefined, undefined)).toBeUndefined(); + }); +}); diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index 1f99c66a5a..df19be454c 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -14,20 +14,6 @@ * limitations under the License. */ -// ----------------------------------------------------------------------- -// Mock / exploration file — not wired into anything. -// -// Shows what a central, reusable Standard-Schema-based utility could -// look like, covering: -// -// 1. Public types for APIs that accept per-field schema records -// 2. Type-level inference (output & input) without any zod types -// 3. Runtime validation with good error messages -// 4. JSON Schema generation from any supported source -// 5. Backward compat with the current (zImpl) => ZodType factory form -// 6. Merging schemas from different sources (blueprint + override) -// ----------------------------------------------------------------------- - import { JsonObject } from '@backstage/types'; import { z as zodV3, type ZodType } from 'zod/v3'; import zodToJsonSchema from 'zod-to-json-schema'; @@ -41,64 +27,60 @@ export type { StandardSchemaV1 } from '@standard-schema/spec'; import { type StandardSchemaV1 } from '@standard-schema/spec'; // --------------------------------------------------------------------------- -// Internal type for the field record accepted by createPortableSchema -// --------------------------------------------------------------------------- - -type FieldSchema = StandardSchemaV1 | ((zImpl: typeof zodV3) => ZodType); - -// --------------------------------------------------------------------------- -// The PortableSchema — now with per-field tracking for mergeability +// Internal types // --------------------------------------------------------------------------- /** - * Internally, each PortableSchema tracks which keys it owns and how to - * validate each one individually. This is the unit of composition: when a - * blueprint defines config fields and an override adds more, each produces - * its own PortableSchema and they're merged at the end. + * Per-field resolved schema — validation is eager, JSON Schema is lazy. + * @internal */ -interface FieldValidator { +interface ResolvedField { validate(value: unknown): { value: unknown } | { errors: string[] }; - jsonSchema: JsonObject; + toJsonSchema(): JsonObject; required: boolean; } /** - * Internal representation that carries per-field validators alongside the - * public PortableSchema surface. The brand field is used to detect whether - * a PortableSchema came from this utility (and thus supports merging). + * 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; + readonly _fields: Record; } // --------------------------------------------------------------------------- -// createPortableSchema — builds from a field record +// createConfigSchema — the primary entry point +// +// Resolves each field, eagerly validates JSON Schema support, and returns +// a PortableSchema whose JSON Schema conversion is lazy. // --------------------------------------------------------------------------- -export function createPortableSchema( - fields: Record, +/** @internal */ +export function createConfigSchema( + fields: Record ZodType)>, ): MergeablePortableSchema { - const fieldValidators: Record = {}; + const resolved: Record = {}; for (const [key, field] of Object.entries(fields)) { - const resolved = typeof field === 'function' ? field(zodV3) : field; - fieldValidators[key] = buildFieldValidator(key, resolved); + const schema = typeof field === 'function' ? field(zodV3) : field; + resolved[key] = resolveField(key, schema); } - return buildPortableSchema(fieldValidators); + return buildPortableSchema(resolved); } // --------------------------------------------------------------------------- // mergePortableSchemas — combines schemas from different sources // -// This is the key operation for blueprint + override composition. Each -// source may use a completely different schema library. Because we track -// per-field validators, merging is just combining the field maps — -// no need to mix schema types within a single validator. +// 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, @@ -120,66 +102,91 @@ export function mergePortableSchemas( } // --------------------------------------------------------------------------- -// buildPortableSchema — internal: produces a PortableSchema from a -// field validator map. This is the shared implementation for both -// createPortableSchema and mergePortableSchemas. +// buildPortableSchema — assembles resolved fields into a PortableSchema +// with per-field validation (eager) and lazy JSON Schema generation. // --------------------------------------------------------------------------- -function buildPortableSchema( - fieldValidators: Record, +function buildPortableSchema( + fields: Record, ): MergeablePortableSchema { - const jsonSchema = buildObjectJsonSchema(fieldValidators); + function parse(input: unknown) { + const inputObj = (input ?? {}) as Record; + const result: Record = {}; + const errors: string[] = []; - return { - parse(input) { - const inputObj = (input ?? {}) as Record; - const result: Record = {}; - const errors: string[] = []; - - for (const [key, validator] of Object.entries(fieldValidators)) { - const validated = validator.validate(inputObj[key]); - if ('errors' in validated) { - errors.push(...validated.errors); - } else { - result[key] = validated.value; - } + for (const [key, field] of Object.entries(fields)) { + const validated = field.validate(inputObj[key]); + if ('errors' in validated) { + errors.push(...validated.errors); + } else { + result[key] = validated.value; } + } - if (errors.length > 0) { - throw new Error(errors.join('; ')); - } + if (errors.length > 0) { + throw new Error(errors.join('; ')); + } - return result as TOutput; - }, + return result as TOutput; + } - schema: jsonSchema, - - _fields: fieldValidators, + const result: MergeablePortableSchema = { + parse, + schema: undefined as any, + _fields: fields, }; + + // schema — lazy getter that produces a callable with JSON Schema + // properties. On first access it computes the JSON Schema once, + // then caches it for subsequent accesses. + 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: true, + }); + + return result; } // --------------------------------------------------------------------------- -// buildFieldValidator — wraps a single schema (any type) into a -// normalized FieldValidator with validation, JSON Schema, and required. +// resolveField — 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 buildFieldValidator(key: string, schema: unknown): FieldValidator { +function resolveField(key: string, schema: unknown): ResolvedField { if (isZodV3Type(schema)) { - return buildZodFieldValidator(key, schema); + return resolveZodField(key, schema); } if (isStandardSchema(schema)) { - return buildStandardFieldValidator(key, schema); + if (!hasJsonSchemaConverter(schema)) { + throw new Error( + `Config schema for field '${key}' does not support JSON Schema ` + + `conversion. Use a schema library that implements the Standard ` + + `JSON Schema interface (like zod v4+), or use a zod v3 schema.`, + ); + } + return resolveStandardField(key, schema); } throw new Error( `Config schema for field '${key}' is not a valid Standard Schema or zod schema`, ); } -function buildZodFieldValidator(key: string, schema: ZodType): FieldValidator { - // Wrap the single field in a one-key z.object so we get proper zod - // object-level behavior (default application, optional handling, etc.) +function resolveZodField(key: string, schema: ZodType): ResolvedField { const wrapper = zodV3.object({ [key]: schema }); - const wholeJsonSchema = zodToJsonSchema(wrapper) as Record; return { validate(value) { @@ -189,26 +196,20 @@ function buildZodFieldValidator(key: string, schema: ZodType): FieldValidator { } return { errors: result.error.issues.map(formatZodIssue) }; }, - jsonSchema: (wholeJsonSchema.properties?.[key] ?? {}) as JsonObject, - required: (wholeJsonSchema.required ?? []).includes(key), + toJsonSchema() { + const wholeJsonSchema = zodToJsonSchema(wrapper) as Record; + return (wholeJsonSchema.properties?.[key] ?? {}) as JsonObject; + }, + required: !schema.isOptional(), }; } -function buildStandardFieldValidator( +function resolveStandardField( key: string, - schema: StandardSchemaV1, -): FieldValidator { - let fieldJsonSchema: JsonObject; - if (hasJsonSchemaConverter(schema)) { - const raw = schema['~standard'].jsonSchema.input({ target: 'draft-07' }); - const { $schema: _, ...rest } = raw; - fieldJsonSchema = rest as JsonObject; - } else { - throw new Error( - `Config schema for field '${key}' does not support JSON Schema conversion`, - ); - } - + schema: StandardSchemaV1 & { + '~standard': { jsonSchema: { input: Function } }; + }, +): ResolvedField { const required = isFieldRequired(schema); return { @@ -228,25 +229,29 @@ function buildStandardFieldValidator( } return { value: result.value }; }, - jsonSchema: fieldJsonSchema, + toJsonSchema() { + const raw = schema['~standard'].jsonSchema.input({ target: 'draft-07' }); + const { $schema: _, ...rest } = raw; + return rest as JsonObject; + }, required, }; } // --------------------------------------------------------------------------- // buildObjectJsonSchema — assembles per-field JSON Schemas into a -// single object-level JSON Schema +// single object-level JSON Schema (called lazily) // --------------------------------------------------------------------------- function buildObjectJsonSchema( - fieldValidators: Record, + fields: Record, ): JsonObject { const properties: Record = {}; const required: string[] = []; - for (const [key, validator] of Object.entries(fieldValidators)) { - properties[key] = validator.jsonSchema; - if (validator.required) { + for (const [key, field] of Object.entries(fields)) { + properties[key] = field.toJsonSchema(); + if (field.required) { required.push(key); } } diff --git a/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts b/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts index 6fc7e3fd02..f71b875976 100644 --- a/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts +++ b/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts @@ -21,24 +21,42 @@ import { PortableSchema } from './types'; /** * @internal + * @deprecated Use {@link createConfigSchema} instead. */ 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('; ')); + let cached: PortableSchema['schema'] | undefined; + + const result: PortableSchema, z.input> = { + parse: input => { + const parseResult = schema.safeParse(input); + if (parseResult.success) { + return parseResult.data; + } + throw new Error(parseResult.error.issues.map(formatIssue).join('; ')); }, - // TODO: Verify why we are not compatible with the latest zodToJsonSchema. - schema: zodToJsonSchema(schema) as JsonObject, + schema: undefined as any, }; + + Object.defineProperty(result, 'schema', { + get() { + if (!cached) { + const jsonSchema = zodToJsonSchema(schema) as JsonObject; + cached = Object.assign( + () => ({ schema: jsonSchema }), + jsonSchema, + ) as PortableSchema['schema']; + } + return cached; + }, + configurable: true, + enumerable: true, + }); + + return result; } function formatIssue(issue: ZodIssue): string { 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 dc30c25b49..2bcb47d50b 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -27,8 +27,10 @@ import { import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; import { ExtensionInput } from './createExtensionInput'; import type { z } from 'zod/v3'; -import { createSchemaFromZod } from '../schema/createSchemaFromZod'; -import { warnConfigSchemaPropDeprecation } from '../schema/createPortableSchema'; +import { + createConfigSchema, + warnConfigSchemaPropDeprecation, +} from '../schema/createPortableSchema'; import { type StandardSchemaV1 } from '@standard-schema/spec'; import { OpaqueExtensionDefinition } from '@internal/frontend'; import { ExtensionDataContainer } from './types'; @@ -647,17 +649,7 @@ export function createExtension( warnConfigSchemaPropDeprecation(); } const resolvedConfigSchema = - schemaDeclaration && - createSchemaFromZod(innerZ => - innerZ.object( - Object.fromEntries( - Object.entries(schemaDeclaration).map(([k, v]) => [ - k, - typeof v === 'function' ? v(innerZ) : v, - ]), - ) as Record, - ), - ); + schemaDeclaration && createConfigSchema(schemaDeclaration); return OpaqueExtensionDefinition.createInstance('v2', { T: undefined as any, From 426d1b389f2f0e81835c7dd7a65ad6e1789f7e03 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 10:17:35 +0200 Subject: [PATCH 07/24] frontend-plugin-api: clean up comments in createPortableSchema Replace box-style section comments with TSDoc comments to match the style used throughout the rest of the repository. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/schema/createPortableSchema.ts | 76 ++++++------------- 1 file changed, 23 insertions(+), 53 deletions(-) diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index df19be454c..c2b816bb29 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -26,10 +26,6 @@ import { PortableSchema } from './types'; export type { StandardSchemaV1 } from '@standard-schema/spec'; import { type StandardSchemaV1 } from '@standard-schema/spec'; -// --------------------------------------------------------------------------- -// Internal types -// --------------------------------------------------------------------------- - /** * Per-field resolved schema — validation is eager, JSON Schema is lazy. * @internal @@ -51,14 +47,11 @@ export interface MergeablePortableSchema readonly _fields: Record; } -// --------------------------------------------------------------------------- -// createConfigSchema — the primary entry point -// -// Resolves each field, eagerly validates JSON Schema support, and returns -// a PortableSchema whose JSON Schema conversion is lazy. -// --------------------------------------------------------------------------- - -/** @internal */ +/** + * Resolves each field, eagerly validates JSON Schema support, and returns + * a PortableSchema whose JSON Schema conversion is lazy. + * @internal + */ export function createConfigSchema( fields: Record ZodType)>, ): MergeablePortableSchema { @@ -72,15 +65,13 @@ export function createConfigSchema( return buildPortableSchema(resolved); } -// --------------------------------------------------------------------------- -// mergePortableSchemas — combines schemas from different sources -// -// 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 */ +/** + * 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, @@ -101,11 +92,10 @@ export function mergePortableSchemas( }); } -// --------------------------------------------------------------------------- -// buildPortableSchema — assembles resolved fields into a PortableSchema -// with per-field validation (eager) and lazy JSON Schema generation. -// --------------------------------------------------------------------------- - +/** + * Assembles resolved fields into a PortableSchema with per-field + * validation (eager) and lazy JSON Schema generation. + */ function buildPortableSchema( fields: Record, ): MergeablePortableSchema { @@ -136,9 +126,7 @@ function buildPortableSchema( _fields: fields, }; - // schema — lazy getter that produces a callable with JSON Schema - // properties. On first access it computes the JSON Schema once, - // then caches it for subsequent accesses. + // Lazy getter — computes JSON Schema on first access, then caches it. let cached: PortableSchema['schema'] | undefined; Object.defineProperty(result, 'schema', { get() { @@ -159,13 +147,11 @@ function buildPortableSchema( return result; } -// --------------------------------------------------------------------------- -// resolveField — 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. -// --------------------------------------------------------------------------- - +/** + * 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)) { return resolveZodField(key, schema); @@ -238,11 +224,7 @@ function resolveStandardField( }; } -// --------------------------------------------------------------------------- -// buildObjectJsonSchema — assembles per-field JSON Schemas into a -// single object-level JSON Schema (called lazily) -// --------------------------------------------------------------------------- - +/** Assembles per-field JSON Schemas into a single object-level JSON Schema. */ function buildObjectJsonSchema( fields: Record, ): JsonObject { @@ -269,10 +251,6 @@ function buildObjectJsonSchema( return schema as JsonObject; } -// --------------------------------------------------------------------------- -// Detection helpers -// --------------------------------------------------------------------------- - function isZodV3Type(value: unknown): value is ZodType { return ( typeof value === 'object' && @@ -308,10 +286,6 @@ function isFieldRequired(schema: StandardSchemaV1): boolean { return (result.issues?.length ?? 0) > 0; } -// --------------------------------------------------------------------------- -// Error formatting -// --------------------------------------------------------------------------- - function formatZodIssue(issue: { code: string; message: string; @@ -349,10 +323,6 @@ function formatStandardIssue( return `${message} at '${path}'`; } -// --------------------------------------------------------------------------- -// Deprecation warning -// --------------------------------------------------------------------------- - let hasWarnedConfigSchemaProp = false; /** @internal */ From 8bb8560557e71c29b683965cedfb936cc7c48d13 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 10:19:51 +0200 Subject: [PATCH 08/24] frontend-plugin-api: always warn on deprecated config.schema with call site Instead of a one-time warning, emit a deprecation warning every time an extension is created using the deprecated `config.schema` option, including the call site location to help track down each usage. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/schema/createPortableSchema.ts | 20 ++++++++----------- .../src/wiring/createExtension.ts | 3 ++- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index c2b816bb29..0abd3224fa 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -323,17 +323,13 @@ function formatStandardIssue( return `${message} at '${path}'`; } -let hasWarnedConfigSchemaProp = false; - /** @internal */ -export function warnConfigSchemaPropDeprecation() { - if (!hasWarnedConfigSchemaProp) { - hasWarnedConfigSchemaProp = true; - // 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 v3.25+ or v4.', - ); - } +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 v3.25+ or v4. ` + + `Declared at ${callSite}`, + ); } diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 2bcb47d50b..643b4dac1d 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -31,6 +31,7 @@ import { createConfigSchema, 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'; @@ -646,7 +647,7 @@ export function createExtension( const schemaDeclaration = options.configSchema ?? (options.config?.schema as any); if (options.config?.schema) { - warnConfigSchemaPropDeprecation(); + warnConfigSchemaPropDeprecation(describeParentCallSite()); } const resolvedConfigSchema = schemaDeclaration && createConfigSchema(schemaDeclaration); From 1ef7954725388c555c2df958d4deb8f8832578f2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 10:39:50 +0200 Subject: [PATCH 09/24] Migrate catalog-react and app blueprints to `configSchema` Move EntityCardBlueprint and EntityContentBlueprint to the new `configSchema` option using zod v3, and AppLanguageApi to zod v4. Fix config schema merge in `makeWithOverrides` when mixing `configSchema` and deprecated `config.schema` sources. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createExtensionBlueprint.ts | 16 +- plugins/app/src/extensions/AppLanguageApi.ts | 9 +- plugins/catalog-react/package.json | 6 +- .../blueprints/EntityCardBlueprint.test.tsx | 162 ++------------- .../alpha/blueprints/EntityCardBlueprint.ts | 12 +- .../EntityContentBlueprint.test.tsx | 190 +++--------------- .../blueprints/EntityContentBlueprint.ts | 18 +- .../EntityContextMenuItemBlueprint.test.tsx | 143 +------------ 8 files changed, 74 insertions(+), 482 deletions(-) diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index bbd8ceeec3..afdbb2e939 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -738,19 +738,15 @@ export function createExtensionBlueprint(options: any): any { if: args.if ?? options.if, inputs: { ...args.inputs, ...options.inputs }, output: (args.output ?? options.output) as ExtensionDataRef[], - config: - options.config || args.config - ? { - schema: { - ...options.config?.schema, - ...args.config?.schema, - }, - } - : undefined, configSchema: - options.configSchema || args.configSchema + options.configSchema || + args.configSchema || + options.config?.schema || + args.config?.schema ? { + ...options.config?.schema, ...options.configSchema, + ...args.config?.schema, ...args.configSchema, } : (undefined as any), 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-react/package.json b/plugins/catalog-react/package.json index 04b05330ff..acaa6ad1c8 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -86,7 +86,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:^", @@ -104,8 +105,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/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index 491f3a2dba..0bf88b18c4 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -45,158 +45,20 @@ 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], + "schema": [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..949f34e48f 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -32,6 +32,7 @@ import { } from '@backstage/filter-predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { Entity } from '@backstage/catalog-model'; +import { z } from 'zod/v3'; /** * @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(), createZodV3FilterPredicateSchema(z)]) + .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..d0dff2a939 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -47,171 +47,35 @@ 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], + "schema": [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..d41f14ff21 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -35,6 +35,7 @@ import { import { resolveEntityFilterData } from './resolveEntityFilterData'; import { Entity } from '@backstage/catalog-model'; import { ReactElement } from 'react'; +import { z } from 'zod/v3'; /** * @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(), createZodV3FilterPredicateSchema(z)]) + .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..463bc7cf60 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -63,144 +63,15 @@ 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], + "schema": [Function], }, "disabled": false, "factory": [Function], From 321802804842086ae664506be45f1e9d8502ba20 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 10:58:21 +0200 Subject: [PATCH 10/24] docs: update extension config schema examples to new `configSchema` format Switch all documentation examples from the deprecated `config: { schema: { field: z => z.type() } }` pattern to the new `configSchema: { field: z.type() }` format using zod v4 imports. Also update catalog-graph README to use current blueprint APIs. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../search/declarative-integration.md | 22 ++++---- .../architecture/20-extensions.md | 16 +++--- .../architecture/23-extension-blueprints.md | 20 +++----- .../architecture/25-extension-overrides.md | 22 ++++---- .../utility-apis/02-creating.md | 8 +-- plugins/catalog-graph/README-alpha.md | 50 ++++++++----------- 6 files changed, 60 insertions(+), 78 deletions(-) diff --git a/docs/features/search/declarative-integration.md b/docs/features/search/declarative-integration.md index 3ad7492114..06d46ae86c 100644 --- a/docs/features/search/declarative-integration.md +++ b/docs/features/search/declarative-integration.md @@ -78,17 +78,16 @@ _Example creating a custom `TechDocsSearchResultItemExtension`_ ```tsx // plugins/techdocs/alpha.tsx import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha'; +import { z } from 'zod'; /** @alpha */ export const TechDocsSearchResultListItemExtension = createSearchResultListItemExtension({ id: 'techdocs', - configSchema: createSchemaFromZod(z => - z.object({ - noTrack: z.boolean().default(false), - lineClamp: z.number().default(5), - }), - ), + configSchema: { + noTrack: z.boolean().default(false), + lineClamp: z.number().default(5), + }, predicate: result => result.type === 'techdocs', component: async ({ config }) => { const { TechDocsSearchResultListItem } = await import( @@ -188,17 +187,16 @@ Here is the `plugins/techdocs/alpha/index.tsx` final version, and you can also t // plugins/techdocs/alpha.tsx import { createPlugin } from '@backstage/frontend-plugin-api'; import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha'; +import { z } from 'zod'; /** @alpha */ export const TechDocsSearchResultListItemExtension = createSearchResultListItemExtension({ id: 'techdocs', - configSchema: createSchemaFromZod(z => - z.object({ - noTrack: z.boolean().default(false), - lineClamp: z.number().default(5), - }), - ), + configSchema: { + noTrack: z.boolean().default(false), + lineClamp: z.number().default(5), + }, predicate: result => result.type === 'techdocs', component: async ({ config }) => { const { TechDocsSearchResultListItem } = await import( diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 14c31764ca..35b166115e 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -278,15 +278,15 @@ In addition to being able to access data passed through the input, you also have 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: ```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/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/plugins/catalog-graph/README-alpha.md b/plugins/catalog-graph/README-alpha.md index b7cd5f65be..0408849737 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({ + EntityCardBlueprint.make({ 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 => ), - }); + params: { + loader: () => + import('./components').then(m => ), }, }), ], @@ -291,29 +280,30 @@ 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 { + createFrontendModule, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; import { convertLegacyRouteRef } from '@backstage/core-compat-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: convertLegacyRouteRef(catalogGraphRouteRef), + loader: () => + import('./components').then(m => ), + }, + }), + ], }); ``` From 1e6d2e91add4ea7a19baaa147ffdd8156666b664 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 11:13:27 +0200 Subject: [PATCH 11/24] frontend-plugin-api: thorough error message tests for portable schema Cover zod v3, zod v4, mixed, and merged schema error messages with exact string assertions to document the error format produced for missing fields, type mismatches, nested paths, and multi-field errors. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/schema/createPortableSchema.test.ts | 356 ++++++++++++++---- 1 file changed, 289 insertions(+), 67 deletions(-) diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts index 0284b99b9a..81b3ce3dc8 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts @@ -14,102 +14,324 @@ * limitations under the License. */ +import { z as zodV4 } from 'zod'; import { createConfigSchema, mergePortableSchemas, } from './createPortableSchema'; describe('createConfigSchema', () => { - it('should provide nice parse errors', () => { - const schema = createConfigSchema({ - foo: z => z.union([z.string(), z.number()]), - derp: z => z.object({ bar: z.number() }), + describe('zod v3 schemas', () => { + it('should report a missing required field', () => { + const schema = createConfigSchema({ name: z => z.string() }); + + expect(() => schema.parse({})).toThrow( + "Missing required value at 'name'", + ); + expect(() => schema.parse(undefined)).toThrow( + "Missing required value at 'name'", + ); }); - expect(() => { - return schema.parse({ derp: { bar: 'derp' } }); - }).toThrow( - `Missing required value at 'foo'; Expected number, received string at 'derp.bar'`, - ); - expect(() => { - return schema.parse(undefined); - }).toThrow(`Missing required value at 'foo'`); - }); + it('should report a type mismatch', () => { + const schema = createConfigSchema({ count: z => z.number() }); - it('should parse valid config', () => { - const schema = createConfigSchema({ - name: z => z.string(), - count: z => z.number().default(0), + expect(() => schema.parse({ count: 'not a number' })).toThrow( + "Expected number, received string at 'count'", + ); }); - expect(schema.parse({ name: 'hello' })).toEqual({ - name: 'hello', - count: 0, + it('should report nested object errors with the full path', () => { + const schema = createConfigSchema({ + settings: z => z.object({ port: z.number() }), + }); + + expect(() => schema.parse({ settings: { port: 'abc' } })).toThrow( + "Expected number, received string at 'settings.port'", + ); }); - expect(schema.parse({ name: 'hi', count: 5 })).toEqual({ - name: 'hi', - count: 5, + + it('should report errors for union types', () => { + const schema = createConfigSchema({ + value: z => z.union([z.string(), z.number()]), + }); + + expect(() => schema.parse({})).toThrow( + "Missing required value at 'value'", + ); + }); + + it('should combine errors from multiple fields', () => { + const schema = createConfigSchema({ + name: z => z.string(), + count: z => z.number(), + }); + + expect(() => schema.parse({})).toThrow( + "Missing required value at 'name'; Missing required value at 'count'", + ); + }); + + it('should apply defaults for optional fields with defaults', () => { + const schema = createConfigSchema({ + name: z => z.string(), + mode: z => z.enum(['fast', 'slow']).default('fast'), + }); + + expect(schema.parse({ name: 'test' })).toEqual({ + name: 'test', + mode: 'fast', + }); }); }); - it('should generate JSON Schema lazily via schema()', () => { - const schema = createConfigSchema({ - title: z => z.string(), - count: z => z.number().optional(), + 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'", + ); }); - 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 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, + }); }); }); - it('should support backward-compatible property access on schema', () => { - const schema = createConfigSchema({ - title: z => z.string(), + describe('mixed zod v3 and v4 schemas', () => { + it('should validate fields from both schema versions', () => { + const schema = createConfigSchema({ + v3field: z => z.string(), + v4field: zodV4.number(), + }); + + expect(schema.parse({ v3field: 'hello', v4field: 42 })).toEqual({ + v3field: 'hello', + v4field: 42, + }); }); - expect(schema.schema.type).toBe('object'); - expect(schema.schema.properties).toBeDefined(); - }); + it('should report errors from both schema versions', () => { + const schema = createConfigSchema({ + v3field: z => z.string(), + v4field: zodV4.number(), + }); - it('should support merging schemas', () => { - const a = createConfigSchema({ - name: z => z.string(), - }); - const b = createConfigSchema({ - count: z => z.number().default(0), + expect(() => schema.parse({})).toThrow( + "Missing required value at 'v3field'; " + + "Invalid input: expected number, received undefined at 'v4field'", + ); }); - const merged = mergePortableSchemas(a, b)!; - expect(merged.parse({ name: 'hello' })).toEqual({ - name: 'hello', - count: 0, - }); + it('should produce correct JSON Schema for mixed schemas', () => { + const schema = createConfigSchema({ + v3field: z => z.string(), + v4field: zodV4.number().optional(), + }); - const result = merged.schema(); - expect(result.schema).toMatchObject({ - type: 'object', - properties: { - name: { type: 'string' }, - count: { type: 'number' }, - }, - required: ['name'], + const result = schema.schema(); + expect(result.schema).toMatchObject({ + type: 'object', + properties: { + v3field: { type: 'string' }, + v4field: { type: 'number' }, + }, + required: ['v3field'], + additionalProperties: false, + }); }); }); - it('should handle merge with undefined', () => { - const a = createConfigSchema({ name: z => z.string() }); + describe('schema creation errors', () => { + it('should reject a schema that is not a valid Standard Schema or zod schema', () => { + expect(() => + createConfigSchema({ bad: { notASchema: true } as any }), + ).toThrow( + "Config schema for field 'bad' is not a valid Standard Schema or zod schema", + ); + }); - expect(mergePortableSchemas(a, undefined)).toBe(a); - expect(mergePortableSchemas(undefined, a)).toBe(a); - expect(mergePortableSchemas(undefined, undefined)).toBeUndefined(); + 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: 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 support backward-compatible property access on schema', () => { + const schema = createConfigSchema({ + title: z => z.string(), + }); + + expect(schema.schema.type).toBe('object'); + expect(schema.schema.properties).toBeDefined(); + }); + }); + + describe('merging schemas', () => { + it('should merge two zod v3 schemas and parse correctly', () => { + const a = createConfigSchema({ name: z => z.string() }); + const b = createConfigSchema({ count: z => z.number().default(0) }); + + const merged = mergePortableSchemas(a, b)!; + expect(merged.parse({ name: 'hello' })).toEqual({ + name: 'hello', + count: 0, + }); + }); + + it('should merge zod v3 and v4 schemas', () => { + const a = createConfigSchema({ 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 = createConfigSchema({ 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 = createConfigSchema({ 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 = createConfigSchema({ 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: z => z.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 = createConfigSchema({ 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'", + ); + }); }); }); From 2d89e198bd809ceccc9db716c9c0386b0b67df85 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 11:18:03 +0200 Subject: [PATCH 12/24] frontend-plugin-api: remove deprecated `createSchemaFromZod` The helper is no longer used internally and has been replaced by the `configSchema` option with direct Standard Schema values. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../fix-zod-generic-frontend-plugin-api.md | 4 +- .../src/schema/createSchemaFromZod.test.ts | 43 ----------- .../src/schema/createSchemaFromZod.ts | 74 ------------------- plugins/api-docs/report-alpha.api.md | 8 +- plugins/catalog-react/report-alpha.api.md | 4 +- plugins/catalog/report-alpha.api.md | 4 +- plugins/kubernetes/report-alpha.api.md | 4 +- plugins/techdocs/report-alpha.api.md | 4 +- 8 files changed, 14 insertions(+), 131 deletions(-) delete mode 100644 packages/frontend-plugin-api/src/schema/createSchemaFromZod.test.ts delete mode 100644 packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts diff --git a/.changeset/fix-zod-generic-frontend-plugin-api.md b/.changeset/fix-zod-generic-frontend-plugin-api.md index 8950ed70c9..e07a32322a 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 `configSchema` option with Standard Schema values instead, for example `configSchema: { title: z.string() }` using zod v3.25+ or v4. 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 f71b875976..0000000000 --- a/packages/frontend-plugin-api/src/schema/createSchemaFromZod.ts +++ /dev/null @@ -1,74 +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 - * @deprecated Use {@link createConfigSchema} instead. - */ -export function createSchemaFromZod( - schemaCreator: (zImpl: typeof z) => TSchema, -): PortableSchema, z.input> { - const schema = schemaCreator(z); - - let cached: PortableSchema['schema'] | undefined; - - const result: PortableSchema, z.input> = { - parse: input => { - const parseResult = schema.safeParse(input); - if (parseResult.success) { - return parseResult.data; - } - throw new Error(parseResult.error.issues.map(formatIssue).join('; ')); - }, - schema: undefined as any, - }; - - Object.defineProperty(result, 'schema', { - get() { - if (!cached) { - const jsonSchema = zodToJsonSchema(schema) as JsonObject; - cached = Object.assign( - () => ({ schema: jsonSchema }), - jsonSchema, - ) as PortableSchema['schema']; - } - return cached; - }, - configurable: true, - enumerable: true, - }); - - return result; -} - -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/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/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 0135fe7d64..a110177e57 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -334,9 +334,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/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/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; }; From 540a03171cbd0ee8140e8abbe7ec76de9801531f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 11:28:38 +0200 Subject: [PATCH 13/24] Add missing changeset and test coverage for PR review gaps Add changeset for catalog-react blueprint migration. Add tests for cross-style config merge (configSchema + deprecated config.schema), deprecation warning emission, and empty configSchema edge case. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../catalog-react-configschema-migration.md | 5 ++ .../src/schema/createPortableSchema.test.ts | 16 +++++ .../wiring/createExtensionBlueprint.test.tsx | 64 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 .changeset/catalog-react-configschema-migration.md diff --git a/.changeset/catalog-react-configschema-migration.md b/.changeset/catalog-react-configschema-migration.md new file mode 100644 index 0000000000..766dbef24f --- /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 direct zod schema values. diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts index 81b3ce3dc8..d612578f32 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts @@ -334,4 +334,20 @@ describe('createConfigSchema', () => { ); }); }); + + 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, + }); + }); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index a76fafac89..e2d420bb38 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -309,6 +309,70 @@ 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: z => z.string().default('default title'), + }, + factory(_, { config }) { + return [coreExtensionData.reactElement(
{config.title}
)]; + }, + }); + + const extension = TestBlueprint.makeWithOverrides({ + name: 'my-extension', + config: { + schema: { + extra: z => z.string(), + }, + }, + factory(origFactory, { config }) { + expect(config.title).toBe('default title'); + expect(config.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', From a2a6c3b72e30dff7f5ca024f7d01644fcbb9102f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 11:51:22 +0200 Subject: [PATCH 14/24] Add migration docs and changeset for new configSchema option Add 1.50 migration section documenting the config.schema to configSchema migration with examples for createExtension, createExtensionBlueprint, and makeWithOverrides. Add a separate changeset for the new feature and update the existing breaking changeset to reference the migration docs. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../fix-zod-generic-frontend-plugin-api.md | 2 +- ...frontend-plugin-api-configschema-option.md | 5 ++ .../architecture/60-migrations.md | 77 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 .changeset/frontend-plugin-api-configschema-option.md diff --git a/.changeset/fix-zod-generic-frontend-plugin-api.md b/.changeset/fix-zod-generic-frontend-plugin-api.md index e07a32322a..00f74f5e18 100644 --- a/.changeset/fix-zod-generic-frontend-plugin-api.md +++ b/.changeset/fix-zod-generic-frontend-plugin-api.md @@ -2,4 +2,4 @@ '@backstage/frontend-plugin-api': minor --- -**BREAKING**: Removed the deprecated `createSchemaFromZod` helper. Use the `configSchema` option with Standard Schema values instead, for example `configSchema: { title: z.string() }` using zod v3.25+ or v4. +**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..03587b7b2b --- /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. The old `config.schema` option is now deprecated. See the [1.50 migration documentation](https://backstage.io/docs/frontend-system/architecture/migrations#150) for more information. diff --git a/docs/frontend-system/architecture/60-migrations.md b/docs/frontend-system/architecture/60-migrations.md index aecc44b039..3ea7f9351e 100644 --- a/docs/frontend-system/architecture/60-migrations.md +++ b/docs/frontend-system/architecture/60-migrations.md @@ -11,6 +11,83 @@ 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, rather than requiring factory functions. The `createSchemaFromZod` helper has also been removed. + +The recommended migration target is [zod v4](https://zod.dev/), but any Standard Schema compatible library or version works, including zod v3.25+. + +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: + +```tsx +import { z } from 'zod'; + +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. + ## 1.31 ### `namespace` parameter should be removed From effa7bf459db8aa0fb02c68e138692723418863a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 12:26:25 +0200 Subject: [PATCH 15/24] Fix missing changesets and tsc errors from CI Add changesets for plugin-app and plugin-catalog-graph. Fix cross-style merge test to use direct schema values with configSchema instead of factory functions. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/app-configschema-migration.md | 5 +++++ .changeset/catalog-graph-docs-update.md | 5 +++++ .../src/wiring/createExtensionBlueprint.test.tsx | 12 ++++++++---- 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 .changeset/app-configschema-migration.md create mode 100644 .changeset/catalog-graph-docs-update.md 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/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index e2d420bb38..dcb2df12ce 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 zodV3 } from 'zod/v3'; import { createExtensionDataContainer, OpaqueExtensionDefinition, @@ -315,10 +316,12 @@ describe('createExtensionBlueprint', () => { attachTo: { id: 'test', input: 'default' }, output: [coreExtensionData.reactElement], configSchema: { - title: z => z.string().default('default title'), + title: zodV3.string().default('default title'), }, factory(_, { config }) { - return [coreExtensionData.reactElement(
{config.title}
)]; + return [ + coreExtensionData.reactElement(
{String(config.title)}
), + ]; }, }); @@ -330,8 +333,9 @@ describe('createExtensionBlueprint', () => { }, }, factory(origFactory, { config }) { - expect(config.title).toBe('default title'); - expect(config.extra).toBe('extra value'); + const c = config as { title: string; extra: string }; + expect(c.title).toBe('default title'); + expect(c.extra).toBe('extra value'); return origFactory({}); }, }); From 8330f1319ab5b3c53ac3d0dc20931e5924ccb118 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 12:52:59 +0200 Subject: [PATCH 16/24] frontend-plugin-api: restore @ignore on internal utility types Route createExtension overloads through CreateExtensionOptions so that ResolvedExtensionInputs, VerifyExtensionFactoryOutput, VerifyExtensionAttachTo, and RequiredExtensionIds can remain @ignore without ae-forgotten-export warnings. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-plugin-api/report-alpha.api.md | 47 -------- packages/frontend-plugin-api/report.api.md | 113 ++++-------------- packages/frontend-plugin-api/src/alpha.ts | 4 - .../src/wiring/createExtension.ts | 74 ++++-------- .../frontend-plugin-api/src/wiring/index.ts | 4 - 5 files changed, 46 insertions(+), 196 deletions(-) diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index bf71434082..61cf25fb21 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -829,27 +829,6 @@ export type PortableSchema = { }; }; -// @public (undocumented) -export type RequiredExtensionIds = - UExtensionData extends any - ? UExtensionData['config']['optional'] extends true - ? never - : UExtensionData['id'] - : never; - -// @public -export type ResolvedExtensionInputs< - TInputs extends { - [name in string]: ExtensionInput; - }, -> = { - [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] - ? Array>> - : false extends TInputs[InputName]['config']['optional'] - ? Expand> - : Expand | undefined>; -}; - // @public export interface RouteRef< TParams extends AnyRouteRefParams = AnyRouteRefParams, @@ -874,31 +853,5 @@ export interface SubRouteRef< readonly T: TParams; } -// @public (undocumented) -export type VerifyExtensionAttachTo< - UOutput extends ExtensionDataRef, - UParentInput extends ExtensionDataRef, -> = ExtensionDataRef extends UParentInput - ? {} - : [RequiredExtensionIds] extends [RequiredExtensionIds] - ? {} - : `Error: This parent extension input requires the following extension data, but it is not declared as guaranteed output of this extension: ${JoinStringUnion< - Exclude, RequiredExtensionIds> - >}`; - -// @public (undocumented) -export type VerifyExtensionFactoryOutput< - UDeclaredOutput extends ExtensionDataRef, - UFactoryOutput extends ExtensionDataValue, -> = [RequiredExtensionIds] extends [UFactoryOutput['id']] - ? [UFactoryOutput['id']] extends [UDeclaredOutput['id']] - ? {} - : `Error: The extension factory has undeclared output(s): ${JoinStringUnion< - Exclude - >}` - : `Error: The extension factory is missing the following output(s): ${JoinStringUnion< - Exclude, UFactoryOutput['id']> - >}`; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 49e4d24bd7..4b54486fd9 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -472,28 +472,18 @@ export function createExtension< [key: string]: StandardSchemaV1; } = {}, >( - options: { - kind?: TKind; - name?: TName; - attachTo: ExtensionDefinitionAttachTo & - VerifyExtensionAttachTo; - disabled?: boolean; - if?: FilterPredicate; - inputs?: TInputs; - output: Array; + options: CreateExtensionOptions< + TKind, + TName, + UOutput, + TInputs, + {}, + UFactoryOutput, + UParentInputs, + TNewConfigSchema + > & { config?: never; - configSchema?: TNewConfigSchema; - factory(context: { - node: AppNode; - apis: ApiHolder; - config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; - }; - inputs: Expand>; - }): Iterable; - } & VerifyExtensionFactoryOutput, + }, ): OverridableExtensionDefinition<{ config: { [key in keyof TNewConfigSchema]: NonNullable< @@ -532,30 +522,18 @@ export function createExtension< const TName extends string | undefined = undefined, UParentInputs extends ExtensionDataRef = ExtensionDataRef, >( - options: { - kind?: TKind; - name?: TName; - attachTo: ExtensionDefinitionAttachTo & - VerifyExtensionAttachTo; - disabled?: boolean; - if?: FilterPredicate; - inputs?: TInputs; - output: Array; + options: CreateExtensionOptions< + TKind, + TName, + UOutput, + TInputs, + TConfigSchema, + UFactoryOutput, + UParentInputs, + {} + > & { configSchema?: never; - config?: { - schema: TConfigSchema; - }; - factory(context: { - node: AppNode; - apis: ApiHolder; - config: { - [key in keyof TConfigSchema]: z.infer< - ReturnType<((...args: any[]) => any) & TConfigSchema[key]> - >; - }; - inputs: Expand>; - }): Iterable; - } & VerifyExtensionFactoryOutput, + }, ): OverridableExtensionDefinition<{ config: string extends keyof TConfigSchema ? {} @@ -2449,27 +2427,6 @@ export const Progress: { // @public (undocumented) export type ProgressProps = {}; -// @public (undocumented) -export type RequiredExtensionIds = - UExtensionData extends any - ? UExtensionData['config']['optional'] extends true - ? never - : UExtensionData['id'] - : never; - -// @public -export type ResolvedExtensionInputs< - TInputs extends { - [name in string]: ExtensionInput; - }, -> = { - [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] - ? Array>> - : false extends TInputs[InputName]['config']['optional'] - ? Expand> - : Expand | undefined>; -}; - // @public export type RouteFunc = ( ...input: TParams extends undefined ? readonly [] : readonly [params: TParams] @@ -2882,32 +2839,6 @@ export const useTranslationRef: ( t: TranslationFunction; }; -// @public (undocumented) -export type VerifyExtensionAttachTo< - UOutput extends ExtensionDataRef, - UParentInput extends ExtensionDataRef, -> = ExtensionDataRef extends UParentInput - ? {} - : [RequiredExtensionIds] extends [RequiredExtensionIds] - ? {} - : `Error: This parent extension input requires the following extension data, but it is not declared as guaranteed output of this extension: ${JoinStringUnion< - Exclude, RequiredExtensionIds> - >}`; - -// @public (undocumented) -export type VerifyExtensionFactoryOutput< - UDeclaredOutput extends ExtensionDataRef, - UFactoryOutput extends ExtensionDataValue, -> = [RequiredExtensionIds] extends [UFactoryOutput['id']] - ? [UFactoryOutput['id']] extends [UDeclaredOutput['id']] - ? {} - : `Error: The extension factory has undeclared output(s): ${JoinStringUnion< - Exclude - >}` - : `Error: The extension factory is missing the following output(s): ${JoinStringUnion< - Exclude, UFactoryOutput['id']> - >}`; - // @public export const vmwareCloudAuthApiRef: ApiRef_2< OAuthApi & diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts index f622efe4f2..d2118994fb 100644 --- a/packages/frontend-plugin-api/src/alpha.ts +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -37,10 +37,6 @@ export type { ExtensionInput, FrontendPlugin, OverridableExtensionDefinition, - ResolvedExtensionInputs, - RequiredExtensionIds, - VerifyExtensionFactoryOutput, - VerifyExtensionAttachTo, } from './wiring'; export type { ApiHolder, diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 643b4dac1d..33f3bca158 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -60,7 +60,7 @@ type ResolvedExtensionInput = /** * Converts an extension input map into a matching collection of resolved inputs. * - * @public + * @ignore */ export type ResolvedExtensionInputs< TInputs extends { @@ -97,7 +97,7 @@ type JoinStringUnion< : JoinStringUnion : TResult; -/** @public */ +/** @ignore */ export type RequiredExtensionIds = UExtensionData extends any ? UExtensionData['config']['optional'] extends true @@ -105,7 +105,7 @@ export type RequiredExtensionIds = : UExtensionData['id'] : never; -/** @public */ +/** @ignore */ export type VerifyExtensionFactoryOutput< UDeclaredOutput extends ExtensionDataRef, UFactoryOutput extends ExtensionDataValue, @@ -119,7 +119,7 @@ export type VerifyExtensionFactoryOutput< Exclude, UFactoryOutput['id']> >}`; -/** @public */ +/** @ignore */ export type VerifyExtensionAttachTo< UOutput extends ExtensionDataRef, UParentInput extends ExtensionDataRef, @@ -527,28 +527,16 @@ export function createExtension< UParentInputs extends ExtensionDataRef = ExtensionDataRef, TNewConfigSchema extends { [key: string]: StandardSchemaV1 } = {}, >( - options: { - kind?: TKind; - name?: TName; - attachTo: ExtensionDefinitionAttachTo & - VerifyExtensionAttachTo; - disabled?: boolean; - if?: FilterPredicate; - inputs?: TInputs; - output: Array; - config?: never; - configSchema?: TNewConfigSchema; - factory(context: { - node: AppNode; - apis: ApiHolder; - config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; - }; - inputs: Expand>; - }): Iterable; - } & VerifyExtensionFactoryOutput, + options: CreateExtensionOptions< + TKind, + TName, + UOutput, + TInputs, + {}, + UFactoryOutput, + UParentInputs, + TNewConfigSchema + > & { config?: never }, ): OverridableExtensionDefinition<{ config: { [key in keyof TNewConfigSchema]: NonNullable< @@ -586,30 +574,16 @@ export function createExtension< const TName extends string | undefined = undefined, UParentInputs extends ExtensionDataRef = ExtensionDataRef, >( - options: { - kind?: TKind; - name?: TName; - attachTo: ExtensionDefinitionAttachTo & - VerifyExtensionAttachTo; - disabled?: boolean; - if?: FilterPredicate; - inputs?: TInputs; - output: Array; - configSchema?: never; - config?: { - schema: TConfigSchema; - }; - factory(context: { - node: AppNode; - apis: ApiHolder; - config: { - [key in keyof TConfigSchema]: z.infer< - ReturnType<((...args: any[]) => any) & TConfigSchema[key]> - >; - }; - inputs: Expand>; - }): Iterable; - } & VerifyExtensionFactoryOutput, + options: CreateExtensionOptions< + TKind, + TName, + UOutput, + TInputs, + TConfigSchema, + UFactoryOutput, + UParentInputs, + {} + > & { configSchema?: never }, ): OverridableExtensionDefinition<{ config: string extends keyof TConfigSchema ? {} diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 676f5982ec..f3a39cc60d 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -22,10 +22,6 @@ export { type ExtensionDefinitionParameters, type CreateExtensionOptions, type OverridableExtensionDefinition, - type ResolvedExtensionInputs, - type RequiredExtensionIds, - type VerifyExtensionFactoryOutput, - type VerifyExtensionAttachTo, } from './createExtension'; export { createExtensionInput, From 27bc970c91b80f9f1b4357f377b3cb8730211da9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 13:16:25 +0200 Subject: [PATCH 17/24] Fix review feedback: override warning, input guard, required tests Pass merged config schemas via configSchema instead of config.schema in the override method to avoid false deprecation warnings. Add type guard for non-object inputs in parse. Add tests for defaulted field required semantics (already correct) and invalid input types. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/schema/createPortableSchema.test.ts | 31 +++++++++++++++++++ .../src/schema/createPortableSchema.ts | 11 +++++++ .../src/wiring/createExtension.ts | 12 +++---- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts index d612578f32..7ea5943f17 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts @@ -349,5 +349,36 @@ describe('createConfigSchema', () => { additionalProperties: false, }); }); + + it('should throw a clear error for non-object input', () => { + const schema = createConfigSchema({ title: z => z.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 mark defaulted zod v3 fields as required in JSON Schema', () => { + const schema = createConfigSchema({ + name: z => z.string(), + title: z => z.string().default('hello'), + count: z => z.number().optional(), + }); + + const result = schema.schema(); + expect(result.schema).toMatchObject({ + type: 'object', + required: ['name'], + }); + }); }); }); diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index 0abd3224fa..e1482475ef 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -100,6 +100,17 @@ 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[] = []; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 33f3bca158..d7d90079a2 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -726,18 +726,16 @@ export function createExtension( ), output: (overrideOptions.output ?? options.output) as ExtensionDataRef[], - config: + configSchema: options.config || options.configSchema || overrideOptions.config || overrideOptions.configSchema ? { - schema: { - ...options.config?.schema, - ...options.configSchema, - ...overrideOptions.config?.schema, - ...overrideOptions.configSchema, - }, + ...options.config?.schema, + ...options.configSchema, + ...overrideOptions.config?.schema, + ...overrideOptions.configSchema, } : undefined, factory: ({ node, apis, config, inputs }) => { From fe8473b5f12848908dcb3d8052fc88b44ed947b2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 13:58:38 +0200 Subject: [PATCH 18/24] Fix review feedback: override warnings and search docs Add explicit deprecation warnings in override and makeWithOverrides when config.schema is used, since the merged result now passes through configSchema. Update search declarative-integration docs to use SearchResultListItemBlueprint instead of the removed extension creator. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../search/declarative-integration.md | 139 +++++------------- .../src/wiring/createExtension.ts | 4 + .../src/wiring/createExtensionBlueprint.ts | 5 + 3 files changed, 43 insertions(+), 105 deletions(-) diff --git a/docs/features/search/declarative-integration.md b/docs/features/search/declarative-integration.md index 06d46ae86c..cea5225f2f 100644 --- a/docs/features/search/declarative-integration.md +++ b/docs/features/search/declarative-integration.md @@ -71,29 +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'; -import { z } from 'zod'; +// plugins/techdocs/src/alpha.tsx +import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha'; -/** @alpha */ export const TechDocsSearchResultListItemExtension = - createSearchResultListItemExtension({ - id: 'techdocs', - configSchema: { - 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 => ; + }, }, }); ``` @@ -106,109 +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'; -import { z } from 'zod'; - -/** @alpha */ -export const TechDocsSearchResultListItemExtension = - createSearchResultListItemExtension({ - id: 'techdocs', - configSchema: { - 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/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index d7d90079a2..a89d138e2f 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -689,6 +689,10 @@ export function createExtension( ); } + 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. diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index afdbb2e939..ec74c7801e 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -38,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 @@ -729,6 +731,9 @@ export function createExtensionBlueprint(options: any): any { }) as OverridableExtensionDefinition; }, makeWithOverrides(args: any) { + if (args.config?.schema) { + warnConfigSchemaPropDeprecation(describeParentCallSite()); + } return createExtension({ kind: options.kind, name: args.name, From 27f6fb30e7c53b087787a48ce340ce3f2d7a29aa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 14:42:40 +0200 Subject: [PATCH 19/24] Update SearchResultListItemBlueprint snapshot The inline snapshot was stale after the PortableSchema internal representation changed to use lazy schema and _fields structure. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../SearchResultListItemBlueprint.test.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx index 3ef5e2d0e9..847a1df635 100644 --- a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx +++ b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx @@ -45,18 +45,15 @@ 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], + "schema": [Function], }, "disabled": false, "factory": [Function], From 461d4fc48bd5cbbf4ab1b22f86ce3ea6134d7780 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 15:09:02 +0200 Subject: [PATCH 20/24] Clean up parse output and schema enumerability Skip assigning undefined keys for absent optional fields in parse, matching the previous zod-object behavior. Make the schema getter non-enumerable so JSON.stringify on the portable schema only serializes parse and _fields. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/blueprints/ApiBlueprint.test.ts | 1 - .../src/blueprints/NavItemBlueprint.test.tsx | 1 - .../src/blueprints/PageBlueprint.test.tsx | 1 - .../src/schema/createPortableSchema.test.ts | 12 ++++++++++++ .../src/schema/createPortableSchema.ts | 4 ++-- .../alpha/blueprints/EntityCardBlueprint.test.tsx | 1 - .../alpha/blueprints/EntityContentBlueprint.test.tsx | 1 - .../EntityContextMenuItemBlueprint.test.tsx | 1 - .../SearchResultListItemBlueprint.test.tsx | 1 - 9 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts index fabc15b3f4..283ce66e8c 100644 --- a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts @@ -190,7 +190,6 @@ describe('ApiBlueprint', () => { }, }, "parse": [Function], - "schema": [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 ccdaa53a55..4efe16df12 100644 --- a/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx @@ -47,7 +47,6 @@ describe('NavItemBlueprint', () => { }, }, "parse": [Function], - "schema": [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 9ac58b3b06..cc043e6283 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx @@ -61,7 +61,6 @@ describe('PageBlueprint', () => { }, }, "parse": [Function], - "schema": [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 index 7ea5943f17..55d5421adb 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts @@ -367,6 +367,18 @@ describe('createConfigSchema', () => { ); }); + it('should not produce undefined keys for absent optional fields', () => { + const schema = createConfigSchema({ + name: z => z.string(), + title: z => z.string().optional(), + count: z => z.number().default(42), + }); + + 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 zod v3 fields as required in JSON Schema', () => { const schema = createConfigSchema({ name: z => z.string(), diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index e1482475ef..2efa93b2e3 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -119,7 +119,7 @@ function buildPortableSchema( const validated = field.validate(inputObj[key]); if ('errors' in validated) { errors.push(...validated.errors); - } else { + } else if (validated.value !== undefined || key in inputObj) { result[key] = validated.value; } } @@ -152,7 +152,7 @@ function buildPortableSchema( return cached; }, configurable: true, - enumerable: true, + enumerable: false, }); return result; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index 0bf88b18c4..776c297fe0 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -58,7 +58,6 @@ describe('EntityCardBlueprint', () => { }, }, "parse": [Function], - "schema": [Function], }, "disabled": false, "factory": [Function], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index d0dff2a939..ff330473bf 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -75,7 +75,6 @@ describe('EntityContentBlueprint', () => { }, }, "parse": [Function], - "schema": [Function], }, "disabled": false, "factory": [Function], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 463bc7cf60..467068c668 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -71,7 +71,6 @@ describe('EntityContextMenuItemBlueprint', () => { }, }, "parse": [Function], - "schema": [Function], }, "disabled": false, "factory": [Function], diff --git a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx index 847a1df635..fe3fd29fc3 100644 --- a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx +++ b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx @@ -53,7 +53,6 @@ describe('SearchResultListItemBlueprint', () => { }, }, "parse": [Function], - "schema": [Function], }, "disabled": false, "factory": [Function], From 5b6061ac778eaf5a24deb6e924bab7a7b89189df Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 16:54:06 +0200 Subject: [PATCH 21/24] Address review feedback: zod import path and legacy route ref Use explicit zod/v4 import path in tests, and remove unnecessary convertLegacyRouteRef wrapper in catalog-graph README. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/schema/createPortableSchema.test.ts | 2 +- plugins/catalog-graph/README-alpha.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts index 55d5421adb..a763c6e188 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { z as zodV4 } from 'zod'; +import { z as zodV4 } from 'zod/v4'; import { createConfigSchema, mergePortableSchemas, diff --git a/plugins/catalog-graph/README-alpha.md b/plugins/catalog-graph/README-alpha.md index 0408849737..67fcf2b4b4 100644 --- a/plugins/catalog-graph/README-alpha.md +++ b/plugins/catalog-graph/README-alpha.md @@ -289,7 +289,6 @@ import { createFrontendModule, PageBlueprint, } from '@backstage/frontend-plugin-api'; -import { convertLegacyRouteRef } from '@backstage/core-compat-api'; import { catalogGraphRouteRef } from '@backstage/plugin-catalog-graph'; export default createFrontendModule({ @@ -298,7 +297,7 @@ export default createFrontendModule({ PageBlueprint.make({ params: { path: '/catalog-graph', - routeRef: convertLegacyRouteRef(catalogGraphRouteRef), + routeRef: catalogGraphRouteRef, loader: () => import('./components').then(m => ), }, From 25392cab0051b6840cfdb17175719b3b7bc22365 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 12 Apr 2026 19:17:39 +0200 Subject: [PATCH 22/24] Use StandardSchemaV1.InferOutput/InferInput utility types Replace raw NonNullable['output'] access with the canonical StandardSchemaV1.InferOutput and InferInput utility types for improved readability. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-plugin-api/report-alpha.api.md | 36 ++++----- packages/frontend-plugin-api/report.api.md | 78 +++++++++---------- .../src/wiring/createExtension.ts | 36 ++++----- .../src/wiring/createExtensionBlueprint.ts | 42 +++++----- 4 files changed, 96 insertions(+), 96 deletions(-) diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index 61cf25fb21..9531ec82c9 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -211,9 +211,9 @@ export interface ExtensionBlueprint< node: AppNode; apis: ApiHolder; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; }; inputs: Expand>; }, @@ -227,16 +227,16 @@ export interface ExtensionBlueprint< }): OverridableExtensionDefinition<{ config: Expand< { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; } & T['config'] >; configInput: Expand< { - [key in keyof TNewExtensionConfigSchema]?: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput< + TNewExtensionConfigSchema[key] + >; } & T['configInput'] >; output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; @@ -618,9 +618,9 @@ export interface OverridableExtensionDefinition< node: AppNode; apis: ApiHolder; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; }; inputs: Expand>; }, @@ -647,14 +647,14 @@ export interface OverridableExtensionDefinition< output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; inputs: T['inputs'] & TExtraInputs; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; }; configInput: T['configInput'] & { - [key in keyof TNewExtensionConfigSchema]?: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput< + TNewExtensionConfigSchema[key] + >; }; }>; // @deprecated (undocumented) diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 4b54486fd9..1d061be9ae 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -486,14 +486,14 @@ export function createExtension< }, ): OverridableExtensionDefinition<{ config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; }; configInput: { - [key in keyof TNewConfigSchema]?: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewConfigSchema]?: StandardSchemaV1.InferInput< + TNewConfigSchema[key] + >; }; output: UOutput extends ExtensionDataRef< infer IData, @@ -602,9 +602,9 @@ export function createExtensionBlueprint< node: AppNode; apis: ApiHolder; config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; }; inputs: Expand>; }, @@ -623,14 +623,14 @@ export function createExtensionBlueprint< : never; inputs: string extends keyof TInputs ? {} : TInputs; config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; }; configInput: { - [key in keyof TNewConfigSchema]?: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewConfigSchema]?: StandardSchemaV1.InferInput< + TNewConfigSchema[key] + >; }; dataRefs: TDataRefs; }>; @@ -756,9 +756,9 @@ export type CreateExtensionBlueprintOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; } & { [key in keyof TConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TConfigSchema[key]> @@ -846,9 +846,9 @@ export type CreateExtensionOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; } & { [key in keyof TConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TConfigSchema[key]> @@ -1264,9 +1264,9 @@ export interface ExtensionBlueprint< node: AppNode; apis: ApiHolder; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; }; inputs: Expand>; }, @@ -1280,16 +1280,16 @@ export interface ExtensionBlueprint< }): OverridableExtensionDefinition<{ config: Expand< { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; } & T['config'] >; configInput: Expand< { - [key in keyof TNewExtensionConfigSchema]?: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput< + TNewExtensionConfigSchema[key] + >; } & T['configInput'] >; output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; @@ -2001,9 +2001,9 @@ export interface OverridableExtensionDefinition< node: AppNode; apis: ApiHolder; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; }; inputs: Expand>; }, @@ -2030,14 +2030,14 @@ export interface OverridableExtensionDefinition< output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; inputs: T['inputs'] & TExtraInputs; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; }; configInput: T['configInput'] & { - [key in keyof TNewExtensionConfigSchema]?: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput< + TNewExtensionConfigSchema[key] + >; }; }>; // @deprecated (undocumented) diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index a89d138e2f..fd6015c016 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -198,9 +198,9 @@ export type CreateExtensionOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; } & { [key in keyof TConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TConfigSchema[key]> @@ -312,9 +312,9 @@ export interface OverridableExtensionDefinition< node: AppNode; apis: ApiHolder; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; }; inputs: Expand>; }, @@ -341,14 +341,14 @@ export interface OverridableExtensionDefinition< output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; inputs: T['inputs'] & TExtraInputs; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; }; configInput: T['configInput'] & { - [key in keyof TNewExtensionConfigSchema]?: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput< + TNewExtensionConfigSchema[key] + >; }; }>; @@ -539,14 +539,14 @@ export function createExtension< > & { config?: never }, ): OverridableExtensionDefinition<{ config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; }; configInput: { - [key in keyof TNewConfigSchema]?: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewConfigSchema]?: StandardSchemaV1.InferInput< + TNewConfigSchema[key] + >; }; output: UOutput extends ExtensionDataRef< infer IData, diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index ec74c7801e..aba81d64f3 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -181,9 +181,9 @@ export type CreateExtensionBlueprintOptions< node: AppNode; apis: ApiHolder; config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; } & { [key in keyof TConfigSchema]: z.infer< ReturnType<((...args: any[]) => any) & TConfigSchema[key]> @@ -310,9 +310,9 @@ export interface ExtensionBlueprint< node: AppNode; apis: ApiHolder; config: T['config'] & { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; }; inputs: Expand>; }, @@ -326,16 +326,16 @@ export interface ExtensionBlueprint< }): OverridableExtensionDefinition<{ config: Expand< { - [key in keyof TNewExtensionConfigSchema]: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewExtensionConfigSchema]: StandardSchemaV1.InferOutput< + TNewExtensionConfigSchema[key] + >; } & T['config'] >; configInput: Expand< { - [key in keyof TNewExtensionConfigSchema]?: NonNullable< - TNewExtensionConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewExtensionConfigSchema]?: StandardSchemaV1.InferInput< + TNewExtensionConfigSchema[key] + >; } & T['configInput'] >; output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; @@ -594,9 +594,9 @@ export function createExtensionBlueprint< node: AppNode; apis: ApiHolder; config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; }; inputs: Expand>; }, @@ -615,14 +615,14 @@ export function createExtensionBlueprint< : never; inputs: string extends keyof TInputs ? {} : TInputs; config: { - [key in keyof TNewConfigSchema]: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['output']; + [key in keyof TNewConfigSchema]: StandardSchemaV1.InferOutput< + TNewConfigSchema[key] + >; }; configInput: { - [key in keyof TNewConfigSchema]?: NonNullable< - TNewConfigSchema[key]['~standard']['types'] - >['input']; + [key in keyof TNewConfigSchema]?: StandardSchemaV1.InferInput< + TNewConfigSchema[key] + >; }; dataRefs: TDataRefs; }>; From 8923d6def005da6804e5003ae8ba66640b9b95ef Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 13 Apr 2026 17:15:46 +0200 Subject: [PATCH 23/24] Drop Zod v3 support from new configSchema path The new `configSchema` option now strictly requires StandardSchemaV1 values (e.g. Zod v4 or `zod/v4` from the Zod v3 package). Direct Zod v3 schemas are no longer silently converted and will throw an error. The deprecated `config.schema` callback path continues to work with Zod v3 through a separate `createDeprecatedConfigSchema` function. Also adds `createZodV4FilterPredicateSchema` to `@backstage/filter-predicates` as a v4 counterpart to the now-deprecated v3 variant. Signed-off-by: Patrik Oldsberg Made-with: Cursor Signed-off-by: Patrik Oldsberg Made-with: Cursor Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../catalog-react-configschema-migration.md | 2 +- .changeset/filter-predicates-v4-schema.md | 5 + ...frontend-plugin-api-configschema-option.md | 2 +- .../architecture/20-extensions.md | 2 +- .../architecture/60-migrations.md | 11 +- packages/core-components/report-alpha.api.md | 2 +- packages/core-components/report.api.md | 2 +- packages/filter-predicates/report.api.md | 7 + .../filter-predicates/src/predicates/index.ts | 1 + .../src/predicates/schema.ts | 52 ++++ .../src/tree/instantiateAppNodeTree.test.ts | 4 +- .../src/schema/createPortableSchema.test.ts | 243 ++++++++---------- .../src/schema/createPortableSchema.ts | 32 ++- .../src/wiring/createExtension.ts | 30 ++- .../wiring/createExtensionBlueprint.test.tsx | 4 +- .../src/wiring/createExtensionBlueprint.ts | 18 +- .../alpha/blueprints/EntityCardBlueprint.ts | 6 +- .../blueprints/EntityContentBlueprint.ts | 6 +- 18 files changed, 256 insertions(+), 173 deletions(-) create mode 100644 .changeset/filter-predicates-v4-schema.md diff --git a/.changeset/catalog-react-configschema-migration.md b/.changeset/catalog-react-configschema-migration.md index 766dbef24f..5cf7506f60 100644 --- a/.changeset/catalog-react-configschema-migration.md +++ b/.changeset/catalog-react-configschema-migration.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-react': patch --- -Migrated alpha entity blueprints to use the new `configSchema` option with direct zod schema values. +Migrated alpha entity blueprints to use the new `configSchema` option with zod v4 schema values. diff --git a/.changeset/filter-predicates-v4-schema.md b/.changeset/filter-predicates-v4-schema.md new file mode 100644 index 0000000000..f72a3b1158 --- /dev/null +++ b/.changeset/filter-predicates-v4-schema.md @@ -0,0 +1,5 @@ +--- +'@backstage/filter-predicates': patch +--- + +Added `createZodV4FilterPredicateSchema` as a zod v4 counterpart to `createZodV3FilterPredicateSchema`. diff --git a/.changeset/frontend-plugin-api-configschema-option.md b/.changeset/frontend-plugin-api-configschema-option.md index 03587b7b2b..85af4ccdad 100644 --- a/.changeset/frontend-plugin-api-configschema-option.md +++ b/.changeset/frontend-plugin-api-configschema-option.md @@ -2,4 +2,4 @@ '@backstage/frontend-plugin-api': patch --- -Added a new `configSchema` option for `createExtension` and `createExtensionBlueprint` that accepts direct schema values from any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library. The old `config.schema` option is now deprecated. See the [1.50 migration documentation](https://backstage.io/docs/frontend-system/architecture/migrations#150) for more information. +Added a new `configSchema` option for `createExtension` and `createExtensionBlueprint` that accepts direct schema values from any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library with JSON Schema support, such as zod v4 or the `zod/v4` subpath from zod v3. The old `config.schema` option is now deprecated. Note that direct zod v3 schemas are not supported by the new `configSchema` option — use `import { z } from 'zod/v4'` from the zod v3 package, or upgrade to zod v4. See the [1.50 migration documentation](https://backstage.io/docs/frontend-system/architecture/migrations#150) for more information. diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 35b166115e..35bbd50489 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -275,7 +275,7 @@ In addition to being able to access data passed through the input, you also have ## Extension configuration -With the `app-config.yaml` there is already the option to pass configuration to plugins or the app to e.g. define the `baseURL` of your app. For extensions this concept would be limiting as an extension can be independent of the plugin & initiated several times. Therefore we created a possibility to configure each extension individually through config. The extension config schema is created using the [`zod`](https://zod.dev/) library, which in addition to TypeScript type checking also provides runtime validation and coercion. If we continue with the example of the `navigationExtension` and now want it to contain a configurable title, we could make it available like the following: +With the `app-config.yaml` there is already the option to pass configuration to plugins or the app to e.g. define the `baseURL` of your app. For extensions this concept would be limiting as an extension can be independent of the plugin & initiated several times. Therefore we created a possibility to configure each extension individually through config. The extension config schema is created using any schema library that implements the [Standard Schema](https://github.com/standard-schema/standard-schema) interface with JSON Schema support, such as [`zod`](https://zod.dev/) v4 (or `import { z } from 'zod/v4'` from the zod v3 package). In addition to TypeScript type checking, the schema also provides runtime validation and coercion. If we continue with the example of the `navigationExtension` and now want it to contain a configurable title, we could make it available like the following: ```tsx import { z } from 'zod'; diff --git a/docs/frontend-system/architecture/60-migrations.md b/docs/frontend-system/architecture/60-migrations.md index 3ea7f9351e..634eeb271e 100644 --- a/docs/frontend-system/architecture/60-migrations.md +++ b/docs/frontend-system/architecture/60-migrations.md @@ -15,9 +15,9 @@ This guide is intended for app and plugin authors who have already migrated thei ### New `configSchema` option for extension config -The `config.schema` option for `createExtension` and `createExtensionBlueprint` is now deprecated in favor of a new top-level `configSchema` option. The new option accepts direct schema values from any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library, rather than requiring factory functions. The `createSchemaFromZod` helper has also been removed. +The `config.schema` option for `createExtension` and `createExtensionBlueprint` is now deprecated in favor of a new top-level `configSchema` option. The new option accepts direct schema values from any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library with JSON Schema support, rather than requiring factory functions. The `createSchemaFromZod` helper has also been removed. -The recommended migration target is [zod v4](https://zod.dev/), but any Standard Schema compatible library or version works, including zod v3.25+. +The `configSchema` option requires schemas that implement the Standard Schema interface with JSON Schema support. This means you need to use [zod v4](https://zod.dev/) or the `zod/v4` subpath export from the zod v3 package (v3.25+). Direct zod v3 schemas are **not** supported by the new `configSchema` option — they are only supported through the deprecated `config.schema` callback format. For example, an extension previously declared like this: @@ -36,10 +36,13 @@ createExtension({ }); ``` -Should now look like this: +Should now look like this, using zod v4 or the `zod/v4` subpath: ```tsx +// Either import from zod v4 directly: import { z } from 'zod'; +// Or use the v4 subpath from the zod v3 package: +// import { z } from 'zod/v4'; createExtension({ // ... @@ -86,7 +89,7 @@ MyBlueprint.makeWithOverrides({ }); ``` -Each field in the `configSchema` record is a standalone schema value rather than a factory function. This decouples the config schema declaration from any specific zod version, and lets you use any schema library that implements the Standard Schema interface. +Each field in the `configSchema` record is a standalone schema value rather than a factory function. This decouples the config schema declaration from any specific zod version, and lets you use any schema library that implements the Standard Schema interface with JSON Schema support. ## 1.31 diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index 76adba09ec..b400d6f6aa 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -27,10 +27,10 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'signIn.title': 'Sign In'; readonly 'signIn.loginFailed': 'Login failed'; readonly 'signIn.customProvider.title': 'Custom User'; + readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.subtitle': 'Enter your own User ID and credentials.\n This selection will not be stored.'; readonly 'signIn.customProvider.userId': 'User ID'; readonly 'signIn.customProvider.tokenInvalid': 'Token is not a valid OpenID Connect JWT Token'; - readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.idToken': 'ID Token (optional)'; readonly 'signIn.guestProvider.title': 'Guest'; readonly 'signIn.guestProvider.enter': 'Enter'; diff --git a/packages/core-components/report.api.md b/packages/core-components/report.api.md index ac6dc5ca15..8ddacb1b96 100644 --- a/packages/core-components/report.api.md +++ b/packages/core-components/report.api.md @@ -245,10 +245,10 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'signIn.title': 'Sign In'; readonly 'signIn.loginFailed': 'Login failed'; readonly 'signIn.customProvider.title': 'Custom User'; + readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.subtitle': 'Enter your own User ID and credentials.\n This selection will not be stored.'; readonly 'signIn.customProvider.userId': 'User ID'; readonly 'signIn.customProvider.tokenInvalid': 'Token is not a valid OpenID Connect JWT Token'; - readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.idToken': 'ID Token (optional)'; readonly 'signIn.guestProvider.title': 'Guest'; readonly 'signIn.guestProvider.enter': 'Enter'; diff --git a/packages/filter-predicates/report.api.md b/packages/filter-predicates/report.api.md index 76edeb61d2..0aea83072f 100644 --- a/packages/filter-predicates/report.api.md +++ b/packages/filter-predicates/report.api.md @@ -5,6 +5,7 @@ ```ts import { Config } from '@backstage/config'; import { JsonValue } from '@backstage/types'; +import { z } from 'zod/v4'; import * as zodV3 from 'zod/v3'; // @public @@ -12,6 +13,12 @@ export function createZodV3FilterPredicateSchema( z: typeof zodV3.z, ): zodV3.ZodType; +// @public +export function createZodV4FilterPredicateSchema(): z.ZodType< + FilterPredicate, + FilterPredicate +>; + // @public export function evaluateFilterPredicate( predicate: FilterPredicate, diff --git a/packages/filter-predicates/src/predicates/index.ts b/packages/filter-predicates/src/predicates/index.ts index 5684c7e62f..57a38f340a 100644 --- a/packages/filter-predicates/src/predicates/index.ts +++ b/packages/filter-predicates/src/predicates/index.ts @@ -26,6 +26,7 @@ export { export { getJsonValueAtPath } from './getJsonValueAtPath'; export { createZodV3FilterPredicateSchema, + createZodV4FilterPredicateSchema, parseFilterPredicate, } from './schema'; export type { diff --git a/packages/filter-predicates/src/predicates/schema.ts b/packages/filter-predicates/src/predicates/schema.ts index 81652786c6..8526748472 100644 --- a/packages/filter-predicates/src/predicates/schema.ts +++ b/packages/filter-predicates/src/predicates/schema.ts @@ -17,6 +17,7 @@ import { InputError } from '@backstage/errors'; import { fromZodError } from 'zod-validation-error/v3'; import * as zodV3 from 'zod/v3'; +import { z as zodV4 } from 'zod/v4'; import { FilterPredicate, FilterPredicateExpression, @@ -69,6 +70,57 @@ export function createZodV3FilterPredicateSchema( return predicateSchema; } +/** + * Creates a zod v4 schema for validating filter predicates. + * + * @public + */ +export function createZodV4FilterPredicateSchema(): zodV4.ZodType< + FilterPredicate, + FilterPredicate +> { + const z = zodV4; + + const primitiveSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + ]) as zodV4.ZodType; + + // eslint-disable-next-line prefer-const + let valuePredicateSchema: zodV4.ZodType< + FilterPredicateValue, + FilterPredicateValue + >; + + const expressionSchema = z.lazy(() => + z.union([ + z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema), + z.record(z.string().regex(/^\$/), z.never()), + ]), + ) as zodV4.ZodType; + + const predicateSchema = z.lazy(() => + z.union([ + expressionSchema, + primitiveSchema, + z.object({ $all: z.array(predicateSchema) }), + z.object({ $any: z.array(predicateSchema) }), + z.object({ $not: predicateSchema }), + ]), + ) as zodV4.ZodType; + + valuePredicateSchema = z.union([ + primitiveSchema, + z.object({ $exists: z.boolean() }), + z.object({ $in: z.array(primitiveSchema) }), + z.object({ $contains: predicateSchema }), + z.object({ $hasPrefix: z.string() }), + ]) as zodV4.ZodType; + + return predicateSchema; +} + /** * Parses a value to check that it's a valid filter predicate. * diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index e8aca98db8..9a60e55033 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -40,7 +40,7 @@ import { resolveExtensionDefinition, } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { createConfigSchema } from '../../../frontend-plugin-api/src/schema/createPortableSchema'; +import { createDeprecatedConfigSchema } from '../../../frontend-plugin-api/src/schema/createPortableSchema'; import { TestApiRegistry, withLogCollector } from '@backstage/test-utils'; import { createErrorCollector } from '../wiring/createErrorCollector'; @@ -181,7 +181,7 @@ describe('instantiateAppNodeTree', () => { test: testDataRef, other: otherDataRef.optional(), }, - configSchema: createConfigSchema({ + configSchema: createDeprecatedConfigSchema({ output: z => z.string().default('test'), other: z => z.number().optional(), }), diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts index a763c6e188..c42298585b 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.test.ts @@ -14,77 +14,15 @@ * limitations under the License. */ +import { z as zodV3 } from 'zod/v3'; import { z as zodV4 } from 'zod/v4'; import { createConfigSchema, + createDeprecatedConfigSchema, mergePortableSchemas, } from './createPortableSchema'; describe('createConfigSchema', () => { - describe('zod v3 schemas', () => { - it('should report a missing required field', () => { - const schema = createConfigSchema({ name: z => z.string() }); - - expect(() => schema.parse({})).toThrow( - "Missing required value at 'name'", - ); - expect(() => schema.parse(undefined)).toThrow( - "Missing required value at 'name'", - ); - }); - - it('should report a type mismatch', () => { - const schema = createConfigSchema({ count: z => z.number() }); - - expect(() => schema.parse({ count: 'not a number' })).toThrow( - "Expected number, received string at 'count'", - ); - }); - - it('should report nested object errors with the full path', () => { - const schema = createConfigSchema({ - settings: z => z.object({ port: z.number() }), - }); - - expect(() => schema.parse({ settings: { port: 'abc' } })).toThrow( - "Expected number, received string at 'settings.port'", - ); - }); - - it('should report errors for union types', () => { - const schema = createConfigSchema({ - value: z => z.union([z.string(), z.number()]), - }); - - expect(() => schema.parse({})).toThrow( - "Missing required value at 'value'", - ); - }); - - it('should combine errors from multiple fields', () => { - const schema = createConfigSchema({ - name: z => z.string(), - count: z => z.number(), - }); - - expect(() => schema.parse({})).toThrow( - "Missing required value at 'name'; Missing required value at 'count'", - ); - }); - - it('should apply defaults for optional fields with defaults', () => { - const schema = createConfigSchema({ - name: z => z.string(), - mode: z => z.enum(['fast', 'slow']).default('fast'), - }); - - expect(schema.parse({ name: 'test' })).toEqual({ - name: 'test', - mode: 'fast', - }); - }); - }); - describe('zod v4 schemas', () => { it('should report a missing required field', () => { const schema = createConfigSchema({ name: zodV4.string() }); @@ -150,57 +88,22 @@ describe('createConfigSchema', () => { }); }); - describe('mixed zod v3 and v4 schemas', () => { - it('should validate fields from both schema versions', () => { - const schema = createConfigSchema({ - v3field: z => z.string(), - v4field: zodV4.number(), - }); - - expect(schema.parse({ v3field: 'hello', v4field: 42 })).toEqual({ - v3field: 'hello', - v4field: 42, - }); - }); - - it('should report errors from both schema versions', () => { - const schema = createConfigSchema({ - v3field: z => z.string(), - v4field: zodV4.number(), - }); - - expect(() => schema.parse({})).toThrow( - "Missing required value at 'v3field'; " + - "Invalid input: expected number, received undefined at 'v4field'", + describe('zod v3 rejection', () => { + it('should reject a direct zod v3 schema', () => { + expect(() => createConfigSchema({ name: zodV3.string() as any })).toThrow( + "Config schema for field 'name' uses a Zod v3 schema, which is " + + 'not supported by the `configSchema` option. Either use ' + + "`import { z } from 'zod/v4'` from the zod v3 package, or " + + 'upgrade to zod v4.', ); }); - - it('should produce correct JSON Schema for mixed schemas', () => { - const schema = createConfigSchema({ - v3field: z => z.string(), - v4field: zodV4.number().optional(), - }); - - const result = schema.schema(); - expect(result.schema).toMatchObject({ - type: 'object', - properties: { - v3field: { type: 'string' }, - v4field: { type: 'number' }, - }, - required: ['v3field'], - additionalProperties: false, - }); - }); }); describe('schema creation errors', () => { - it('should reject a schema that is not a valid Standard Schema or zod schema', () => { + it('should reject a schema that is not a valid Standard Schema', () => { expect(() => createConfigSchema({ bad: { notASchema: true } as any }), - ).toThrow( - "Config schema for field 'bad' is not a valid Standard Schema or zod schema", - ); + ).toThrow("Config schema for field 'bad' is not a valid Standard Schema"); }); it('should reject a Standard Schema without JSON Schema support', () => { @@ -223,8 +126,8 @@ describe('createConfigSchema', () => { describe('JSON Schema generation', () => { it('should generate JSON Schema lazily via schema()', () => { const schema = createConfigSchema({ - title: z => z.string(), - count: z => z.number().optional(), + title: zodV4.string(), + count: zodV4.number().optional(), }); const result = schema.schema(); @@ -242,7 +145,7 @@ describe('createConfigSchema', () => { it('should support backward-compatible property access on schema', () => { const schema = createConfigSchema({ - title: z => z.string(), + title: zodV4.string(), }); expect(schema.schema.type).toBe('object'); @@ -251,9 +154,9 @@ describe('createConfigSchema', () => { }); describe('merging schemas', () => { - it('should merge two zod v3 schemas and parse correctly', () => { - const a = createConfigSchema({ name: z => z.string() }); - const b = createConfigSchema({ count: z => z.number().default(0) }); + it('should merge two zod v4 schemas and parse correctly', () => { + const a = createConfigSchema({ name: zodV4.string() }); + const b = createConfigSchema({ count: zodV4.number().default(0) }); const merged = mergePortableSchemas(a, b)!; expect(merged.parse({ name: 'hello' })).toEqual({ @@ -262,8 +165,8 @@ describe('createConfigSchema', () => { }); }); - it('should merge zod v3 and v4 schemas', () => { - const a = createConfigSchema({ name: z => z.string() }); + it('should merge deprecated v3 and new v4 schemas', () => { + const a = createDeprecatedConfigSchema({ name: z => z.string() }); const b = createConfigSchema({ count: zodV4.number().default(0) }); const merged = mergePortableSchemas(a, b)!; @@ -274,7 +177,7 @@ describe('createConfigSchema', () => { }); it('should produce combined errors after merge', () => { - const a = createConfigSchema({ name: z => z.string() }); + const a = createDeprecatedConfigSchema({ name: z => z.string() }); const b = createConfigSchema({ count: zodV4.number() }); const merged = mergePortableSchemas(a, b)!; @@ -286,7 +189,7 @@ describe('createConfigSchema', () => { }); it('should produce combined errors for type mismatches after merge', () => { - const a = createConfigSchema({ name: z => z.string() }); + const a = createDeprecatedConfigSchema({ name: z => z.string() }); const b = createConfigSchema({ count: zodV4.number() }); const merged = mergePortableSchemas(a, b)!; @@ -298,7 +201,7 @@ describe('createConfigSchema', () => { }); it('should produce correct JSON Schema after merge', () => { - const a = createConfigSchema({ name: z => z.string() }); + const a = createDeprecatedConfigSchema({ name: z => z.string() }); const b = createConfigSchema({ count: zodV4.number().optional() }); const merged = mergePortableSchemas(a, b)!; @@ -316,7 +219,7 @@ describe('createConfigSchema', () => { }); it('should handle merge with undefined', () => { - const a = createConfigSchema({ name: z => z.string() }); + const a = createConfigSchema({ name: zodV4.string() }); expect(mergePortableSchemas(a, undefined)).toBe(a); expect(mergePortableSchemas(undefined, a)).toBe(a); @@ -324,7 +227,7 @@ describe('createConfigSchema', () => { }); it('should let later fields win when merging overlapping keys', () => { - const a = createConfigSchema({ x: z => z.string() }); + const a = createDeprecatedConfigSchema({ x: z => z.string() }); const b = createConfigSchema({ x: zodV4.number() }); const merged = mergePortableSchemas(a, b)!; @@ -351,7 +254,7 @@ describe('createConfigSchema', () => { }); it('should throw a clear error for non-object input', () => { - const schema = createConfigSchema({ title: z => z.string() }); + const schema = createConfigSchema({ title: zodV4.string() }); expect(() => schema.parse('not an object')).toThrow( 'Invalid config input, expected object but got string', @@ -369,9 +272,9 @@ describe('createConfigSchema', () => { it('should not produce undefined keys for absent optional fields', () => { const schema = createConfigSchema({ - name: z => z.string(), - title: z => z.string().optional(), - count: z => z.number().default(42), + name: zodV4.string(), + title: zodV4.string().optional(), + count: zodV4.number().default(42), }); const result = schema.parse({ name: 'hello' }); @@ -379,11 +282,11 @@ describe('createConfigSchema', () => { expect(Object.keys(result as object)).toEqual(['name', 'count']); }); - it('should not mark defaulted zod v3 fields as required in JSON Schema', () => { + it('should not mark defaulted fields as required in JSON Schema', () => { const schema = createConfigSchema({ - name: z => z.string(), - title: z => z.string().default('hello'), - count: z => z.number().optional(), + name: zodV4.string(), + title: zodV4.string().default('hello'), + count: zodV4.number().optional(), }); const result = schema.schema(); @@ -394,3 +297,85 @@ describe('createConfigSchema', () => { }); }); }); + +describe('createDeprecatedConfigSchema', () => { + it('should report a missing required field', () => { + const schema = createDeprecatedConfigSchema({ name: z => z.string() }); + + expect(() => schema.parse({})).toThrow("Missing required value at 'name'"); + expect(() => schema.parse(undefined)).toThrow( + "Missing required value at 'name'", + ); + }); + + it('should report a type mismatch', () => { + const schema = createDeprecatedConfigSchema({ count: z => z.number() }); + + expect(() => schema.parse({ count: 'not a number' })).toThrow( + "Expected number, received string at 'count'", + ); + }); + + it('should report nested object errors with the full path', () => { + const schema = createDeprecatedConfigSchema({ + settings: z => z.object({ port: z.number() }), + }); + + expect(() => schema.parse({ settings: { port: 'abc' } })).toThrow( + "Expected number, received string at 'settings.port'", + ); + }); + + it('should report errors for union types', () => { + const schema = createDeprecatedConfigSchema({ + value: z => z.union([z.string(), z.number()]), + }); + + expect(() => schema.parse({})).toThrow("Missing required value at 'value'"); + }); + + it('should apply defaults for optional fields with defaults', () => { + const schema = createDeprecatedConfigSchema({ + name: z => z.string(), + mode: z => z.enum(['fast', 'slow']).default('fast'), + }); + + expect(schema.parse({ name: 'test' })).toEqual({ + name: 'test', + mode: 'fast', + }); + }); + + it('should generate JSON Schema lazily via schema()', () => { + const schema = createDeprecatedConfigSchema({ + title: z => z.string(), + count: z => z.number().optional(), + }); + + const result = schema.schema(); + expect(result).toHaveProperty('schema'); + expect(result.schema).toMatchObject({ + type: 'object', + properties: { + title: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['title'], + additionalProperties: false, + }); + }); + + it('should not mark defaulted fields as required in JSON Schema', () => { + const schema = createDeprecatedConfigSchema({ + name: z => z.string(), + title: z => z.string().default('hello'), + count: z => z.number().optional(), + }); + + const result = schema.schema(); + expect(result.schema).toMatchObject({ + type: 'object', + required: ['name'], + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts index 2efa93b2e3..a6bb3340d7 100644 --- a/packages/frontend-plugin-api/src/schema/createPortableSchema.ts +++ b/packages/frontend-plugin-api/src/schema/createPortableSchema.ts @@ -26,6 +26,19 @@ import { PortableSchema } from './types'; export type { StandardSchemaV1 } from '@standard-schema/spec'; import { type StandardSchemaV1 } from '@standard-schema/spec'; +/** @internal */ +export function createDeprecatedConfigSchema( + fields: Record ZodType>, +): MergeablePortableSchema { + const resolved: Record = {}; + + for (const [key, field] of Object.entries(fields)) { + resolved[key] = resolveZodField(key, field(zodV3)); + } + + return buildPortableSchema(resolved); +} + /** * Per-field resolved schema — validation is eager, JSON Schema is lazy. * @internal @@ -53,13 +66,12 @@ export interface MergeablePortableSchema * @internal */ export function createConfigSchema( - fields: Record ZodType)>, + fields: Record, ): MergeablePortableSchema { const resolved: Record = {}; for (const [key, field] of Object.entries(fields)) { - const schema = typeof field === 'function' ? field(zodV3) : field; - resolved[key] = resolveField(key, schema); + resolved[key] = resolveField(key, field); } return buildPortableSchema(resolved); @@ -165,20 +177,25 @@ function buildPortableSchema( */ function resolveField(key: string, schema: unknown): ResolvedField { if (isZodV3Type(schema)) { - return resolveZodField(key, schema); + throw new Error( + `Config schema for field '${key}' uses a Zod v3 schema, which is ` + + `not supported by the \`configSchema\` option. Either use ` + + `\`import { z } from 'zod/v4'\` from the zod v3 package, or ` + + `upgrade to zod v4.`, + ); } if (isStandardSchema(schema)) { if (!hasJsonSchemaConverter(schema)) { throw new Error( `Config schema for field '${key}' does not support JSON Schema ` + `conversion. Use a schema library that implements the Standard ` + - `JSON Schema interface (like zod v4+), or use a zod v3 schema.`, + `JSON Schema interface (like zod v4+).`, ); } return resolveStandardField(key, schema); } throw new Error( - `Config schema for field '${key}' is not a valid Standard Schema or zod schema`, + `Config schema for field '${key}' is not a valid Standard Schema`, ); } @@ -340,7 +357,8 @@ export function warnConfigSchemaPropDeprecation(callSite: string) { console.warn( `DEPRECATION WARNING: The \`config.schema\` option for extension config is deprecated. ` + `Use the \`configSchema\` option instead with Standard Schema values, for example ` + - `\`configSchema: { title: z.string() }\` using zod v3.25+ or v4. ` + + `\`configSchema: { title: z.string() }\` using zod v4 ` + + `(or \`import { z } from 'zod/v4'\` from the zod v3 package). ` + `Declared at ${callSite}`, ); } diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index fd6015c016..6735fc9e5d 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -29,6 +29,8 @@ import { ExtensionInput } from './createExtensionInput'; import type { z } from 'zod/v3'; import { createConfigSchema, + createDeprecatedConfigSchema, + mergePortableSchemas, warnConfigSchemaPropDeprecation, } from '../schema/createPortableSchema'; import { describeParentCallSite } from '../routing/describeParentCallSite'; @@ -618,13 +620,15 @@ export function createExtension< export function createExtension( options: any, ): OverridableExtensionDefinition { - const schemaDeclaration = - options.configSchema ?? (options.config?.schema as any); if (options.config?.schema) { warnConfigSchemaPropDeprecation(describeParentCallSite()); } - const resolvedConfigSchema = - schemaDeclaration && createConfigSchema(schemaDeclaration); + const resolvedConfigSchema = mergePortableSchemas( + options.config?.schema + ? createDeprecatedConfigSchema(options.config.schema) + : undefined, + options.configSchema ? createConfigSchema(options.configSchema) : undefined, + ); return OpaqueExtensionDefinition.createInstance('v2', { T: undefined as any, @@ -730,15 +734,19 @@ export function createExtension( ), output: (overrideOptions.output ?? options.output) as ExtensionDataRef[], - configSchema: - options.config || - options.configSchema || - overrideOptions.config || - overrideOptions.configSchema + config: + options.config?.schema || overrideOptions.config?.schema + ? { + schema: { + ...options.config?.schema, + ...overrideOptions.config?.schema, + }, + } + : undefined, + configSchema: + options.configSchema || overrideOptions.configSchema ? { - ...options.config?.schema, ...options.configSchema, - ...overrideOptions.config?.schema, ...overrideOptions.configSchema, } : undefined, diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index dcb2df12ce..17405c8b9d 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -31,7 +31,7 @@ import { import { createExtensionInput } from './createExtensionInput'; import { RouteRef } from '../routing'; import { createExtension, ExtensionDefinition } from './createExtension'; -import { z as zodV3 } from 'zod/v3'; +import { z as zodV4 } from 'zod/v4'; import { createExtensionDataContainer, OpaqueExtensionDefinition, @@ -316,7 +316,7 @@ describe('createExtensionBlueprint', () => { attachTo: { id: 'test', input: 'default' }, output: [coreExtensionData.reactElement], configSchema: { - title: zodV3.string().default('default title'), + title: zodV4.string().default('default title'), }, factory(_, { config }) { return [ diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index aba81d64f3..99ed7c1afb 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -743,15 +743,19 @@ export function createExtensionBlueprint(options: any): any { if: args.if ?? options.if, inputs: { ...args.inputs, ...options.inputs }, output: (args.output ?? options.output) as ExtensionDataRef[], - configSchema: - options.configSchema || - args.configSchema || - options.config?.schema || - args.config?.schema + config: + options.config?.schema || args.config?.schema + ? { + schema: { + ...options.config?.schema, + ...args.config?.schema, + }, + } + : undefined, + configSchema: + options.configSchema || args.configSchema ? { - ...options.config?.schema, ...options.configSchema, - ...args.config?.schema, ...args.configSchema, } : (undefined as any), diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 949f34e48f..e6d68a31be 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -28,11 +28,11 @@ import { } from './extensionData'; import { FilterPredicate, - createZodV3FilterPredicateSchema, + createZodV4FilterPredicateSchema, } from '@backstage/filter-predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { Entity } from '@backstage/catalog-model'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; /** * @alpha @@ -54,7 +54,7 @@ export const EntityCardBlueprint = createExtensionBlueprint({ }, configSchema: { filter: z - .union([z.string(), createZodV3FilterPredicateSchema(z)]) + .union([z.string(), createZodV4FilterPredicateSchema()]) .optional(), type: z.enum(entityCardTypes).optional(), }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index d41f14ff21..d534ee0f2b 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -30,12 +30,12 @@ import { } from './extensionData'; import { FilterPredicate, - createZodV3FilterPredicateSchema, + createZodV4FilterPredicateSchema, } from '@backstage/filter-predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { Entity } from '@backstage/catalog-model'; import { ReactElement } from 'react'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; /** * @alpha @@ -65,7 +65,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ path: z.string().optional(), title: z.string().optional(), filter: z - .union([z.string(), createZodV3FilterPredicateSchema(z)]) + .union([z.string(), createZodV4FilterPredicateSchema()]) .optional(), group: z.literal(false).or(z.string()).optional(), icon: z.string().optional(), From 86b98390449195edaf89446f5dba274f3a4d23de Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 13 Apr 2026 22:31:29 +0200 Subject: [PATCH 24/24] Fix override name in catalog-graph README The entity card override example used `name: 'entity-relations'` but the actual extension uses `name: 'relations'`. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/catalog-graph/README-alpha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-graph/README-alpha.md b/plugins/catalog-graph/README-alpha.md index 67fcf2b4b4..f5500e5b3f 100644 --- a/plugins/catalog-graph/README-alpha.md +++ b/plugins/catalog-graph/README-alpha.md @@ -199,7 +199,7 @@ export default createFrontendModule({ pluginId: 'catalog-graph', extensions: [ EntityCardBlueprint.make({ - name: 'entity-relations', + name: 'relations', params: { loader: () => import('./components').then(m => ),