From 1f80e1a5a4b47bd7ebe19ad15394f6fda4df25f5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 10 Apr 2026 10:13:19 +0200 Subject: [PATCH] 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,