exploration: Standard Schema utility for zod version decoupling
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 <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -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<Input = unknown, Output = Input> {
|
||||
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace StandardSchemaV1 {
|
||||
export interface Props<Input = unknown, Output = Input> {
|
||||
readonly version: 1;
|
||||
readonly vendor: string;
|
||||
readonly validate: (
|
||||
value: unknown,
|
||||
options?: Options | undefined,
|
||||
) => Result<Output> | Promise<Result<Output>>;
|
||||
readonly types?: Types<Input, Output> | undefined;
|
||||
}
|
||||
|
||||
export type Result<Output> = SuccessResult<Output> | FailureResult;
|
||||
|
||||
export interface SuccessResult<Output> {
|
||||
readonly value: Output;
|
||||
readonly issues?: undefined;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly libraryOptions?: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface FailureResult {
|
||||
readonly issues: ReadonlyArray<Issue>;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
readonly message: string;
|
||||
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
|
||||
}
|
||||
|
||||
export interface PathSegment {
|
||||
readonly key: PropertyKey;
|
||||
}
|
||||
|
||||
export interface Types<Input = unknown, Output = Input> {
|
||||
readonly input: Input;
|
||||
readonly output: Output;
|
||||
}
|
||||
|
||||
export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
|
||||
Schema['~standard']['types']
|
||||
>['input'];
|
||||
|
||||
export type InferOutput<Schema extends StandardSchemaV1> = 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 ConfigFieldSchema> = 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<ReturnType<T[key]>> }`
|
||||
*/
|
||||
type InferConfigOutput<T extends ConfigSchemaRecord> = {
|
||||
[K in keyof T]: StandardSchemaV1.InferOutput<ResolveField<T[K]>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers the raw input type (before defaults/transforms) of a config
|
||||
* schema record.
|
||||
* Replaces: `z.input<z.ZodObject<{ ... }>>`
|
||||
*/
|
||||
type InferConfigInput<T extends ConfigSchemaRecord> = {
|
||||
[K in keyof T]: StandardSchemaV1.InferInput<ResolveField<T[K]>>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<TOutput = any, TInput = any>
|
||||
extends PortableSchema<TOutput, TInput> {
|
||||
/** @internal */
|
||||
readonly _fields: Record<string, FieldValidator>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createPortableSchema — builds from a field record
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createPortableSchema<T extends ConfigSchemaRecord>(
|
||||
fields: T,
|
||||
): MergeablePortableSchema<InferConfigOutput<T>, InferConfigInput<T>> {
|
||||
const fieldValidators: Record<string, FieldValidator> = {};
|
||||
|
||||
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, B>(
|
||||
a: MergeablePortableSchema<A> | undefined,
|
||||
b: MergeablePortableSchema<B> | undefined,
|
||||
): MergeablePortableSchema<A & B> | undefined {
|
||||
if (!a && !b) {
|
||||
return undefined;
|
||||
}
|
||||
if (!a) {
|
||||
return b as MergeablePortableSchema<A & B>;
|
||||
}
|
||||
if (!b) {
|
||||
return a as MergeablePortableSchema<A & B>;
|
||||
}
|
||||
|
||||
return buildPortableSchema<A & B>({
|
||||
...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<TOutput>(
|
||||
fieldValidators: Record<string, FieldValidator>,
|
||||
): MergeablePortableSchema<TOutput> {
|
||||
const jsonSchema = buildObjectJsonSchema(fieldValidators);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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<string, any>;
|
||||
|
||||
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<string, FieldValidator>,
|
||||
): JsonObject {
|
||||
const properties: Record<string, JsonObject> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [key, validator] of Object.entries(fieldValidators)) {
|
||||
properties[key] = validator.jsonSchema;
|
||||
if (validator.required) {
|
||||
required.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const schema: Record<string, unknown> = {
|
||||
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<string | number>;
|
||||
unionErrors?: Array<{ issues: Array<any> }>;
|
||||
}): 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,
|
||||
};
|
||||
Reference in New Issue
Block a user