From a9d6797b7bbccf113e8cfc444be72d44008408c0 Mon Sep 17 00:00:00 2001 From: Oleg Ivaniv Date: Thu, 6 Feb 2025 15:39:55 +0100 Subject: [PATCH] fix(Call n8n Sub-Workflow Tool Node): Fix json type when using $fromAI --- .../ToolWorkflow/v2/utils/FromAIParser.ts | 56 +++++++++++++++- .../__tests__/create-node-as-tool.test.ts | 66 ++++++++++++++++++- packages/workflow/src/FromAIParseUtils.ts | 56 +++++++++++++++- 3 files changed, 173 insertions(+), 5 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts index 4b9b6ed58ed4e..da80ab0611d2c 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts @@ -48,9 +48,61 @@ export class AIParametersParser { case 'boolean': schema = z.boolean(); break; - case 'json': - schema = z.record(z.any()); + case 'json': { + interface CustomSchemaDef extends z.ZodTypeDef { + jsonSchema?: { + anyOf: [ + { + type: 'object'; + minProperties: number; + additionalProperties: boolean; + }, + { + type: 'array'; + minItems: number; + }, + ]; + }; + } + + // Create a custom schema to validate that the incoming data is either a non-empty object or a non-empty array. + const customSchema = z.custom | unknown[]>( + (data: unknown) => { + if (data === null || typeof data !== 'object') return false; + if (Array.isArray(data)) { + return data.length > 0; + } + return Object.keys(data).length > 0; + }, + { + message: 'Value must be a non-empty object or a non-empty array', + }, + ); + + // Cast the custom schema to a type that includes our JSON metadata. + const typedSchema = customSchema as z.ZodType< + Record | unknown[], + CustomSchemaDef + >; + + // Attach the updated `jsonSchema` metadata to the internal definition. + typedSchema._def.jsonSchema = { + anyOf: [ + { + type: 'object', + minProperties: 1, + additionalProperties: true, + }, + { + type: 'array', + minItems: 1, + }, + ], + }; + + schema = typedSchema; break; + } default: schema = z.string(); } diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts index 450ec99247e26..fdf442680e9f2 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts @@ -319,7 +319,7 @@ describe('createNodeAsTool', () => { const tool = createNodeAsTool(options).response; - expect(tool.schema.shape.complexJson._def.innerType).toBeInstanceOf(z.ZodRecord); + expect(tool.schema.shape.complexJson._def.innerType).toBeInstanceOf(z.ZodEffects); expect(tool.schema.shape.complexJson.description).toBe('Param with complex JSON default'); expect(tool.schema.shape.complexJson._def.defaultValue()).toEqual({ nested: { key: 'value' }, @@ -478,4 +478,68 @@ describe('createNodeAsTool', () => { expect(tool.schema.shape.unicodeParam.description).toBe('🌈 Unicode parameter 你好'); }); }); + + // Additional tests for JSON type parsing based on recent changes + describe('JSON Type Parsing and Metadata', () => { + it('should correctly parse a JSON parameter without default', () => { + node.parameters = { + jsonWithoutDefault: + "={{ $fromAI('jsonWithoutDefault', 'JSON parameter without default', 'json') }}", + }; + + const tool = createNodeAsTool(options).response; + // Valid JSON objects (with at least one property) should pass. + const validJson = { key: 'value' }; + expect(() => tool.schema.shape.jsonWithoutDefault.parse(validJson)).not.toThrow(); + + // Parsing an empty object should throw a validation error. + expect(() => tool.schema.shape.jsonWithoutDefault.parse({})).toThrow( + 'Value must be a non-empty object or a non-empty array', + ); + }); + + it('should correctly parse a JSON parameter with a valid default', () => { + node.parameters = { + jsonWithValidDefault: + "={{ $fromAI('jsonWithValidDefault', 'JSON parameter with valid default', 'json', '{\"key\": \"defaultValue\"}') }}", + }; + + const tool = createNodeAsTool(options).response; + // The default value should be parsed from the provided JSON string. + expect(tool.schema.shape.jsonWithValidDefault._def.defaultValue()).toEqual({ + key: 'defaultValue', + }); + }); + + it('should throw an error if provided JSON default is an empty object', () => { + node.parameters = { + jsonEmptyDefault: + "={{ $fromAI('jsonEmptyDefault', 'JSON parameter with empty default', 'json', '{}') }}", + }; + + const tool = createNodeAsTool(options).response; + // The default value is an empty object. + expect(tool.schema.shape.jsonEmptyDefault._def.defaultValue()).toEqual({}); + // Parsing an empty object should fail. + expect(() => tool.schema.shape.jsonEmptyDefault.parse({})).toThrow( + 'Value must be a non-empty object or a non-empty array', + ); + }); + + it('should use provided JSON value over the default value', () => { + node.parameters = { + jsonParamCustom: + "={{ $fromAI('jsonParamCustom', 'JSON parameter with custom default', 'json', '{\"initial\": \"value\"}') }}", + }; + + const tool = createNodeAsTool(options).response; + // Check that the default value is correctly set. + expect(tool.schema.shape.jsonParamCustom._def.defaultValue()).toEqual({ initial: 'value' }); + + // When a new valid value is provided, the schema should use it. + const newValue = { newKey: 'newValue' }; + const parsedResult = tool.schema.shape.jsonParamCustom.parse(newValue); + expect(parsedResult).toEqual(newValue); + }); + }); }); diff --git a/packages/workflow/src/FromAIParseUtils.ts b/packages/workflow/src/FromAIParseUtils.ts index 4d86040b2343f..b96c91ec25e40 100644 --- a/packages/workflow/src/FromAIParseUtils.ts +++ b/packages/workflow/src/FromAIParseUtils.ts @@ -34,9 +34,61 @@ export function generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny { case 'boolean': schema = z.boolean(); break; - case 'json': - schema = z.record(z.any()); + case 'json': { + interface CustomSchemaDef extends z.ZodTypeDef { + jsonSchema?: { + anyOf: [ + { + type: 'object'; + minProperties: number; + additionalProperties: boolean; + }, + { + type: 'array'; + minItems: number; + }, + ]; + }; + } + + // Create a custom schema to validate that the incoming data is either a non-empty object or a non-empty array. + const customSchema = z.custom | unknown[]>( + (data: unknown) => { + if (data === null || typeof data !== 'object') return false; + if (Array.isArray(data)) { + return data.length > 0; + } + return Object.keys(data).length > 0; + }, + { + message: 'Value must be a non-empty object or a non-empty array', + }, + ); + + // Cast the custom schema to a type that includes our JSON metadata. + const typedSchema = customSchema as z.ZodType< + Record | unknown[], + CustomSchemaDef + >; + + // Attach the updated `jsonSchema` metadata to the internal definition. + typedSchema._def.jsonSchema = { + anyOf: [ + { + type: 'object', + minProperties: 1, + additionalProperties: true, + }, + { + type: 'array', + minItems: 1, + }, + ], + }; + + schema = typedSchema; break; + } default: schema = z.string(); }