frontend-plugin-api: per-field config schema with lazy JSON Schema
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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -819,9 +819,14 @@ export type PluginWrapperDefinition<TValue = unknown | never> = {
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type PortableSchema<TOutput, TInput = TOutput> = {
|
||||
export type PortableSchema<TOutput = unknown, TInput = TOutput> = {
|
||||
parse: (input: TInput) => TOutput;
|
||||
schema: JsonObject;
|
||||
schema: {
|
||||
(): {
|
||||
schema: JsonObject;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -2418,9 +2418,14 @@ export type PluginWrapperDefinition<TValue = unknown | never> = {
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type PortableSchema<TOutput, TInput = TOutput> = {
|
||||
export type PortableSchema<TOutput = unknown, TInput = TOutput> = {
|
||||
parse: (input: TInput) => TOutput;
|
||||
schema: JsonObject;
|
||||
schema: {
|
||||
(): {
|
||||
schema: JsonObject;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<TOutput = any, TInput = any>
|
||||
extends PortableSchema<TOutput, TInput> {
|
||||
/** @internal */
|
||||
readonly _fields: Record<string, FieldValidator>;
|
||||
readonly _fields: Record<string, ResolvedField>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, FieldSchema>,
|
||||
/** @internal */
|
||||
export function createConfigSchema(
|
||||
fields: Record<string, StandardSchemaV1 | ((zImpl: typeof zodV3) => ZodType)>,
|
||||
): MergeablePortableSchema {
|
||||
const fieldValidators: Record<string, FieldValidator> = {};
|
||||
const resolved: Record<string, ResolvedField> = {};
|
||||
|
||||
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, B>(
|
||||
a: MergeablePortableSchema<A> | undefined,
|
||||
b: MergeablePortableSchema<B> | undefined,
|
||||
@@ -120,66 +102,91 @@ export function mergePortableSchemas<A, B>(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<TOutput>(
|
||||
fieldValidators: Record<string, FieldValidator>,
|
||||
function buildPortableSchema<TOutput = unknown>(
|
||||
fields: Record<string, ResolvedField>,
|
||||
): MergeablePortableSchema<TOutput> {
|
||||
const jsonSchema = buildObjectJsonSchema(fieldValidators);
|
||||
function parse(input: unknown) {
|
||||
const inputObj = (input ?? {}) as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
const errors: string[] = [];
|
||||
|
||||
return {
|
||||
parse(input) {
|
||||
const inputObj = (input ?? {}) as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
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<TOutput> = {
|
||||
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<string, any>;
|
||||
|
||||
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<string, any>;
|
||||
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<string, FieldValidator>,
|
||||
fields: Record<string, ResolvedField>,
|
||||
): JsonObject {
|
||||
const properties: Record<string, JsonObject> = {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,24 +21,42 @@ import { PortableSchema } from './types';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @deprecated Use {@link createConfigSchema} instead.
|
||||
*/
|
||||
export function createSchemaFromZod<TSchema extends ZodType>(
|
||||
schemaCreator: (zImpl: typeof z) => TSchema,
|
||||
): PortableSchema<z.output<TSchema>, z.input<TSchema>> {
|
||||
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.output<TSchema>, z.input<TSchema>> = {
|
||||
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 {
|
||||
|
||||
@@ -17,7 +17,16 @@
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
/** @public */
|
||||
export type PortableSchema<TOutput, TInput = TOutput> = {
|
||||
export type PortableSchema<TOutput = unknown, TInput = TOutput> = {
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -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<string, z.ZodTypeAny>,
|
||||
),
|
||||
);
|
||||
schemaDeclaration && createConfigSchema(schemaDeclaration);
|
||||
|
||||
return OpaqueExtensionDefinition.createInstance('v2', {
|
||||
T: undefined as any,
|
||||
|
||||
Reference in New Issue
Block a user