Bugfix/Zod Parsing (#5399)

* implement parseWithTypeConversion - parse a value against a Zod schema with automatic type conversion for common type mismatches

* Enhance parseWithTypeConversion to include maxDepth parameter for recursion control, preventing infinite loops during parsing.
This commit is contained in:
Henry Heng
2025-10-31 13:03:12 +00:00
committed by GitHub
parent 0149688a16
commit 4417102f6c
7 changed files with 186 additions and 13 deletions
+162 -1
View File
@@ -4,11 +4,11 @@ import * as fs from 'fs'
import * as path from 'path'
import { JSDOM } from 'jsdom'
import { z } from 'zod'
import { cloneDeep, omit, get } from 'lodash'
import TurndownService from 'turndown'
import { DataSource, Equal } from 'typeorm'
import { ICommonObject, IDatabaseEntity, IFileUpload, IMessage, INodeData, IVariable, MessageContentImageUrl } from './Interface'
import { AES, enc } from 'crypto-js'
import { omit, get } from 'lodash'
import { AIMessage, HumanMessage, BaseMessage } from '@langchain/core/messages'
import { Document } from '@langchain/core/documents'
import { getFileFromStorage } from './storageUtils'
@@ -1760,3 +1760,164 @@ export const parseJsonBody = (body: string): any => {
}
}
}
/**
* Parse a value against a Zod schema with automatic type conversion for common type mismatches
* @param schema - The Zod schema to parse against
* @param arg - The value to parse
* @param maxDepth - Maximum recursion depth to prevent infinite loops (default: 10)
* @returns The parsed value
* @throws Error if parsing fails after attempting type conversions
*/
export async function parseWithTypeConversion<T extends z.ZodTypeAny>(schema: T, arg: unknown, maxDepth: number = 10): Promise<z.infer<T>> {
// Safety check: prevent infinite recursion
if (maxDepth <= 0) {
throw new Error('Maximum recursion depth reached in parseWithTypeConversion')
}
try {
return await schema.parseAsync(arg)
} catch (e) {
// Check if it's a ZodError and try to fix type mismatches
if (z.ZodError && e instanceof z.ZodError) {
const zodError = e as z.ZodError
// Deep clone the arg to avoid mutating the original
const modifiedArg = typeof arg === 'object' && arg !== null ? cloneDeep(arg) : arg
let hasModification = false
// Helper function to set a value at a nested path
const setValueAtPath = (obj: any, path: (string | number)[], value: any): void => {
let current = obj
for (let i = 0; i < path.length - 1; i++) {
const key = path[i]
if (current && typeof current === 'object' && key in current) {
current = current[key]
} else {
return // Path doesn't exist
}
}
if (current !== undefined && current !== null) {
const finalKey = path[path.length - 1]
current[finalKey] = value
}
}
// Helper function to get a value at a nested path
const getValueAtPath = (obj: any, path: (string | number)[]): any => {
let current = obj
for (const key of path) {
if (current && typeof current === 'object' && key in current) {
current = current[key]
} else {
return undefined
}
}
return current
}
// Helper function to convert value to expected type
const convertValue = (value: any, expected: string, received: string): any => {
// Expected string
if (expected === 'string') {
if (received === 'object' || received === 'array') {
return JSON.stringify(value)
}
if (received === 'number' || received === 'boolean') {
return String(value)
}
}
// Expected number
else if (expected === 'number') {
if (received === 'string') {
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
return parsed
}
}
if (received === 'boolean') {
return value ? 1 : 0
}
}
// Expected boolean
else if (expected === 'boolean') {
if (received === 'string') {
const lower = String(value).toLowerCase().trim()
if (lower === 'true' || lower === '1' || lower === 'yes') {
return true
}
if (lower === 'false' || lower === '0' || lower === 'no') {
return false
}
}
if (received === 'number') {
return value !== 0
}
}
// Expected object
else if (expected === 'object') {
if (received === 'string') {
try {
const parsed = JSON.parse(value)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed
}
} catch {
// Invalid JSON, return undefined to skip conversion
}
}
}
// Expected array
else if (expected === 'array') {
if (received === 'string') {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed
}
} catch {
// Invalid JSON, return undefined to skip conversion
}
}
if (received === 'object' && value !== null) {
// Convert object to array (e.g., {0: 'a', 1: 'b'} -> ['a', 'b'])
// Only if it looks like an array-like object
const keys = Object.keys(value)
const numericKeys = keys.filter((k) => /^\d+$/.test(k))
if (numericKeys.length === keys.length) {
return numericKeys.map((k) => value[k])
}
}
}
return undefined // No conversion possible
}
// Process each issue in the error
for (const issue of zodError.issues) {
// Handle invalid_type errors (type mismatches)
if (issue.code === 'invalid_type' && issue.path.length > 0) {
try {
const valueAtPath = getValueAtPath(modifiedArg, issue.path)
if (valueAtPath !== undefined) {
const convertedValue = convertValue(valueAtPath, issue.expected, issue.received)
if (convertedValue !== undefined) {
setValueAtPath(modifiedArg, issue.path, convertedValue)
hasModification = true
}
}
} catch (pathError) {
console.error('Error processing path in Zod error', pathError)
}
}
}
// If we modified the arg, recursively call parseWithTypeConversion
// This allows newly surfaced nested errors to also get conversion treatment
// Decrement maxDepth to prevent infinite recursion
if (hasModification) {
return await parseWithTypeConversion(schema, modifiedArg, maxDepth - 1)
}
}
// Re-throw the original error if not a ZodError or no conversion possible
throw e
}
}