// Schema Transformer // Converts Pydantic-generated JSON Schema to RJSF-compatible format with uiSchema import type { RJSFSchema, UiSchema } from "@rjsf/utils"; export interface TransformedSchema { schema: RJSFSchema; uiSchema: UiSchema; } export interface UiSchemaOptions { /** Field ordering for the schema */ fieldOrder?: string[]; /** Fields to hide from the form */ hiddenFields?: string[]; /** Fields to mark as advanced (collapsed by default) */ advancedFields?: string[]; /** Custom widget mappings */ widgetMappings?: Record; /** Whether to include descriptions */ includeDescriptions?: boolean; /** i18n namespace for field labels (e.g., "config/detect") */ i18nNamespace?: string; } // Type guard for schema objects function isSchemaObject( schema: unknown, ): schema is RJSFSchema & Record { return typeof schema === "object" && schema !== null; } function schemaHasType(schema: Record, type: string): boolean { const schemaType = schema.type; if (Array.isArray(schemaType)) { return schemaType.includes(type); } return schemaType === type; } /** * Normalizes nullable schemas by unwrapping anyOf/oneOf [Type, null] patterns. * * When Pydantic generates JSON Schema for optional fields (e.g., Optional[int]), * it creates anyOf/oneOf unions like: [{ type: "integer", ... }, { type: "null" }] * * This causes RJSF to treat the field as a multi-schema field with a dropdown selector, * which leads to the field disappearing when the value is cleared (becomes undefined/null). * * This function unwraps these simple nullable patterns to a single non-null schema, * allowing fields to remain visible and functional even when empty. * * @example * // Input: { anyOf: [{ type: "integer" }, { type: "null" }] } * // Output: { type: "integer" } * * @example * // Input: { oneOf: [{ type: "string" }, { type: "null" }] } * // Output: { type: "string" } */ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema { if (!isSchemaObject(schema)) { return schema; } const schemaObj = schema as Record; if ( schemaObj.default === null && schemaObj.type && !Array.isArray(schemaObj.type) && schemaObj.type !== "null" ) { schemaObj.type = [schemaObj.type, "null"]; } const anyOf = schemaObj.anyOf; if (Array.isArray(anyOf)) { const hasNull = anyOf.some( (item) => isSchemaObject(item) && (item as Record).type === "null", ); const nonNull = anyOf.find( (item) => isSchemaObject(item) && (item as Record).type !== "null", ) as RJSFSchema | undefined; if (hasNull && nonNull && anyOf.length === 2) { const normalizedNonNull = normalizeNullableSchema(nonNull as RJSFSchema); const normalizedNonNullObj = normalizedNonNull as Record; const nonNullType = normalizedNonNullObj.type; const mergedType = Array.isArray(nonNullType) ? Array.from(new Set([...nonNullType, "null"])) : nonNullType ? [nonNullType, "null"] : ["null"]; const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj; return { ...rest, ...normalizedNonNullObj, type: mergedType, } as RJSFSchema; } return { ...schemaObj, anyOf: anyOf .filter(isSchemaObject) .map((item) => normalizeNullableSchema(item as RJSFSchema)), } as RJSFSchema; } const oneOf = schemaObj.oneOf; if (Array.isArray(oneOf)) { const hasNull = oneOf.some( (item) => isSchemaObject(item) && (item as Record).type === "null", ); const nonNull = oneOf.find( (item) => isSchemaObject(item) && (item as Record).type !== "null", ) as RJSFSchema | undefined; if (hasNull && nonNull && oneOf.length === 2) { const normalizedNonNull = normalizeNullableSchema(nonNull as RJSFSchema); const normalizedNonNullObj = normalizedNonNull as Record; const nonNullType = normalizedNonNullObj.type; const mergedType = Array.isArray(nonNullType) ? Array.from(new Set([...nonNullType, "null"])) : nonNullType ? [nonNullType, "null"] : ["null"]; const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj; return { ...rest, ...normalizedNonNullObj, type: mergedType, } as RJSFSchema; } return { ...schemaObj, oneOf: oneOf .filter(isSchemaObject) .map((item) => normalizeNullableSchema(item as RJSFSchema)), } as RJSFSchema; } if (isSchemaObject(schemaObj.properties)) { const normalizedProps: Record = {}; for (const [key, prop] of Object.entries( schemaObj.properties as Record, )) { if (isSchemaObject(prop)) { normalizedProps[key] = normalizeNullableSchema(prop as RJSFSchema); } } return { ...schemaObj, properties: normalizedProps } as RJSFSchema; } if (schemaObj.items) { if (Array.isArray(schemaObj.items)) { return { ...schemaObj, items: schemaObj.items .filter(isSchemaObject) .map((item) => normalizeNullableSchema(item as RJSFSchema)), } as RJSFSchema; } else if (isSchemaObject(schemaObj.items)) { return { ...schemaObj, items: normalizeNullableSchema(schemaObj.items as RJSFSchema), } as RJSFSchema; } } if ( schemaObj.additionalProperties && isSchemaObject(schemaObj.additionalProperties) ) { return { ...schemaObj, additionalProperties: normalizeNullableSchema( schemaObj.additionalProperties as RJSFSchema, ), } as RJSFSchema; } return schema; } /** * Resolves $ref references in a JSON Schema * This converts Pydantic's $defs-based schema to inline schemas */ export function resolveSchemaRefs( schema: RJSFSchema, rootSchema?: RJSFSchema, ): RJSFSchema { const root = rootSchema || schema; const defs = (root as Record).$defs || (root as Record).definitions || {}; if (!schema || typeof schema !== "object") { return schema; } const schemaObj = schema as Record; // Handle $ref if (schemaObj.$ref && typeof schemaObj.$ref === "string") { const refPath = schemaObj.$ref .replace(/^#\/\$defs\//, "") .replace(/^#\/definitions\//, ""); const resolved = (defs as Record)[refPath]; if (isSchemaObject(resolved)) { // Merge any additional properties from the original schema const { $ref: _ref, ...rest } = schemaObj; return resolveSchemaRefs({ ...resolved, ...rest } as RJSFSchema, root); } return schema; } // Handle allOf (Pydantic uses this for inheritance) if (Array.isArray(schemaObj.allOf)) { const merged: Record = {}; for (const subSchema of schemaObj.allOf) { if (isSchemaObject(subSchema)) { const resolved = resolveSchemaRefs(subSchema as RJSFSchema, root); Object.assign(merged, resolved); if ( isSchemaObject(resolved) && (resolved as Record).properties ) { merged.properties = { ...(merged.properties as object), ...((resolved as Record).properties as object), }; } if ( isSchemaObject(resolved) && Array.isArray((resolved as Record).required) ) { merged.required = [ ...((merged.required as string[]) || []), ...((resolved as Record).required as string[]), ]; } } } // Include any extra properties from the parent schema const { allOf: _allOf, ...rest } = schemaObj; return { ...merged, ...rest } as RJSFSchema; } // Handle anyOf/oneOf if (Array.isArray(schemaObj.anyOf)) { return { ...schemaObj, anyOf: schemaObj.anyOf .filter(isSchemaObject) .map((s) => resolveSchemaRefs(s as RJSFSchema, root)), } as RJSFSchema; } if (Array.isArray(schemaObj.oneOf)) { return { ...schemaObj, oneOf: schemaObj.oneOf .filter(isSchemaObject) .map((s) => resolveSchemaRefs(s as RJSFSchema, root)), } as RJSFSchema; } // Handle properties if (isSchemaObject(schemaObj.properties)) { const resolvedProps: Record = {}; for (const [key, prop] of Object.entries( schemaObj.properties as Record, )) { if (isSchemaObject(prop)) { resolvedProps[key] = resolveSchemaRefs(prop as RJSFSchema, root); } } return { ...schemaObj, properties: resolvedProps } as RJSFSchema; } // Handle items (for arrays) if (schemaObj.items) { if (Array.isArray(schemaObj.items)) { return { ...schemaObj, items: schemaObj.items .filter(isSchemaObject) .map((item) => resolveSchemaRefs(item as RJSFSchema, root)), } as RJSFSchema; } else if (isSchemaObject(schemaObj.items)) { return { ...schemaObj, items: resolveSchemaRefs(schemaObj.items as RJSFSchema, root), } as RJSFSchema; } } // Handle additionalProperties (for dicts) if ( schemaObj.additionalProperties && isSchemaObject(schemaObj.additionalProperties) ) { return { ...schemaObj, additionalProperties: resolveSchemaRefs( schemaObj.additionalProperties as RJSFSchema, root, ), } as RJSFSchema; } return schema; } /** * Wrapper that resolves refs and strips $defs from result * Use this as the main entry point for resolving schemas */ export function resolveAndCleanSchema(schema: RJSFSchema): RJSFSchema { const resolved = resolveSchemaRefs(schema); // Remove $defs from result - they're no longer needed after resolution const { $defs: _defs, definitions: _definitions, ...cleanSchema } = resolved as Record; return cleanSchema as RJSFSchema; } /** * Determines the appropriate widget for a schema field */ function getWidgetForField( fieldName: string, fieldSchema: RJSFSchema, customMappings?: Record, ): string | undefined { // Check custom mappings first if (customMappings?.[fieldName]) { return customMappings[fieldName]; } const schemaObj = fieldSchema as Record; // Password fields if ( fieldName.toLowerCase().includes("password") || fieldName.toLowerCase().includes("secret") ) { return "password"; } // Color fields if ( fieldName.toLowerCase().includes("color") && schemaHasType(schemaObj, "object") ) { return "color"; } // Enum fields get select widget if (schemaObj.enum) { return "select"; } // Boolean fields get switch widget if (schemaHasType(schemaObj, "boolean")) { return "switch"; } // Number with range gets slider if ( (schemaHasType(schemaObj, "number") || schemaHasType(schemaObj, "integer")) && schemaObj.minimum !== undefined && schemaObj.maximum !== undefined ) { return "range"; } // Array of strings gets tags widget if ( schemaHasType(schemaObj, "array") && isSchemaObject(schemaObj.items) && (schemaObj.items as Record).type === "string" ) { return "tags"; } return undefined; } /** * Generates a uiSchema for a given JSON Schema */ function generateUiSchema( schema: RJSFSchema, options: UiSchemaOptions = {}, ): UiSchema { const uiSchema: UiSchema = {}; const { fieldOrder, hiddenFields = [], advancedFields = [], widgetMappings = {}, includeDescriptions = true, } = options; const schemaObj = schema as Record; // Set field ordering if (fieldOrder && fieldOrder.length > 0) { uiSchema["ui:order"] = [...fieldOrder, "*"]; } if (!isSchemaObject(schemaObj.properties)) { return uiSchema; } for (const [fieldName, fieldSchema] of Object.entries( schemaObj.properties as Record, )) { if (!isSchemaObject(fieldSchema)) continue; const fSchema = fieldSchema as Record; const fieldUiSchema: UiSchema = {}; // Hidden fields if (hiddenFields.includes(fieldName)) { fieldUiSchema["ui:widget"] = "hidden"; uiSchema[fieldName] = fieldUiSchema; continue; } // Widget selection const widget = getWidgetForField( fieldName, fieldSchema as RJSFSchema, widgetMappings, ); if (widget) { fieldUiSchema["ui:widget"] = widget; } // Description if (!includeDescriptions && fSchema.description) { fieldUiSchema["ui:description"] = ""; } // Advanced fields - mark for collapsible if (advancedFields.includes(fieldName)) { fieldUiSchema["ui:options"] = { ...((fieldUiSchema["ui:options"] as object) || {}), advanced: true, }; } // Handle nested objects recursively if ( schemaHasType(fSchema, "object") && isSchemaObject(fSchema.properties) ) { const nestedOptions: UiSchemaOptions = { hiddenFields: hiddenFields .filter((f) => f.startsWith(`${fieldName}.`)) .map((f) => f.replace(`${fieldName}.`, "")), advancedFields: advancedFields .filter((f) => f.startsWith(`${fieldName}.`)) .map((f) => f.replace(`${fieldName}.`, "")), widgetMappings: Object.fromEntries( Object.entries(widgetMappings) .filter(([k]) => k.startsWith(`${fieldName}.`)) .map(([k, v]) => [k.replace(`${fieldName}.`, ""), v]), ), includeDescriptions, }; Object.assign( fieldUiSchema, generateUiSchema(fieldSchema as RJSFSchema, nestedOptions), ); } if (Object.keys(fieldUiSchema).length > 0) { uiSchema[fieldName] = fieldUiSchema; } } return uiSchema; } /** * Transforms a Pydantic JSON Schema to RJSF format * Resolves references and generates appropriate uiSchema */ export function transformSchema( rawSchema: RJSFSchema, options: UiSchemaOptions = {}, ): TransformedSchema { // Resolve all $ref references and clean the result const cleanSchema = resolveAndCleanSchema(rawSchema); const normalizedSchema = normalizeNullableSchema(cleanSchema); // Generate uiSchema const uiSchema = generateUiSchema(normalizedSchema, options); return { schema: normalizedSchema, uiSchema, }; } /** * Extracts a subsection of the schema by path * Useful for rendering individual config sections */ export function extractSchemaSection( schema: RJSFSchema, path: string, ): RJSFSchema | null { const schemaObj = schema as Record; const defs = (schemaObj.$defs || schemaObj.definitions || {}) as Record< string, unknown >; const parts = path.split("."); let current = schema as Record; for (const part of parts) { if (!isSchemaObject(current.properties)) { return null; } let propSchema = (current.properties as Record)[ part ] as Record; if (!propSchema) { return null; } // Resolve $ref if present if (propSchema.$ref && typeof propSchema.$ref === "string") { const refPath = (propSchema.$ref as string) .replace(/^#\/\$defs\//, "") .replace(/^#\/definitions\//, ""); const resolved = defs[refPath] as Record; if (resolved) { // Merge any additional properties from original const { $ref: _ref, ...rest } = propSchema; propSchema = { ...resolved, ...rest }; } else { return null; } } current = propSchema; } // Return section schema with $defs included for nested ref resolution const sectionWithDefs = { ...current, $defs: defs, } as RJSFSchema; // Resolve all nested refs and clean the result return resolveAndCleanSchema(sectionWithDefs); } /** * Merges default values from schema into form data */ export function applySchemaDefaults( schema: RJSFSchema, formData: Record = {}, ): Record { const result = { ...formData }; const schemaObj = schema as Record; if (!isSchemaObject(schemaObj.properties)) { return result; } for (const [key, prop] of Object.entries( schemaObj.properties as Record, )) { if (!isSchemaObject(prop)) continue; const propSchema = prop as Record; if ( result[key] === undefined && propSchema.default !== undefined && propSchema.default !== null ) { result[key] = propSchema.default; } else if ( schemaHasType(propSchema, "object") && isSchemaObject(propSchema.properties) && result[key] !== undefined ) { result[key] = applySchemaDefaults( prop as RJSFSchema, result[key] as Record, ); } } return result; }