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, +};