diff --git a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts index eee9f9bffc..7d467dd252 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts @@ -11,8 +11,13 @@ import type { } from 'n8n-workflow'; import type { z } from 'zod'; -import { inputSchemaField, jsonSchemaExampleField, schemaTypeField } from '@utils/descriptions'; -import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; +import { + buildJsonSchemaExampleNotice, + inputSchemaField, + jsonSchemaExampleField, + schemaTypeField, +} from '@utils/descriptions'; +import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing'; import { getBatchingOptionFields } from '@utils/sharedFields'; import { SYSTEM_PROMPT_TEMPLATE } from './constants'; @@ -27,7 +32,8 @@ export class InformationExtractor implements INodeType { icon: 'fa:project-diagram', iconColor: 'black', group: ['transform'], - version: [1, 1.1], + version: [1, 1.1, 1.2], + defaultVersion: 1.2, description: 'Extract information from text in a structured format', codex: { alias: ['NER', 'parse', 'parsing', 'JSON', 'data extraction', 'structured'], @@ -88,6 +94,11 @@ export class InformationExtractor implements INodeType { "cities": ["Los Angeles", "San Francisco", "San Diego"] }`, }, + buildJsonSchemaExampleNotice({ + showExtraProps: { + '@version': [{ _cnd: { gte: 1.2 } }], + }, + }), { ...inputSchemaField, default: `{ @@ -242,7 +253,10 @@ export class InformationExtractor implements INodeType { if (schemaType === 'fromJson') { const jsonExample = this.getNodeParameter('jsonSchemaExample', 0, '') as string; - jsonSchema = generateSchema(jsonExample); + // Enforce all fields to be required in the generated schema if the node version is 1.2 or higher + const jsonExampleAllFieldsRequired = this.getNode().typeVersion >= 1.2; + + jsonSchema = generateSchemaFromExample(jsonExample, jsonExampleAllFieldsRequired); } else { const inputSchema = this.getNodeParameter('inputSchema', 0, '') as string; jsonSchema = jsonParse(inputSchema); diff --git a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/InformationExtraction.node.test.ts b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/InformationExtraction.node.test.ts index 76b1b22cf6..6f81dec576 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/InformationExtraction.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/InformationExtraction.node.test.ts @@ -1,7 +1,8 @@ import type { BaseLanguageModel } from '@langchain/core/language_models/base'; import { FakeListChatModel } from '@langchain/core/utils/testing'; +import { mock } from 'jest-mock-extended'; import get from 'lodash/get'; -import type { IDataObject, IExecuteFunctions } from 'n8n-workflow'; +import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow'; import { makeZodSchemaFromAttributes } from '../helpers'; import { InformationExtractor } from '../InformationExtractor.node'; @@ -88,6 +89,208 @@ describe('InformationExtractor', () => { }); }); + describe('Single Item Processing with JSON Schema from Example', () => { + it('should extract information using JSON schema from example - version 1.2 (required fields)', async () => { + const node = new InformationExtractor(); + const inputData = [ + { + json: { text: 'John lives in California and has visited Los Angeles and San Francisco' }, + }, + ]; + + const mockExecuteFunctions = createExecuteFunctionsMock( + { + text: 'John lives in California and has visited Los Angeles and San Francisco', + schemaType: 'fromJson', + jsonSchemaExample: JSON.stringify({ + state: 'California', + cities: ['Los Angeles', 'San Francisco'], + }), + options: { + systemPromptTemplate: '', + }, + }, + new FakeListChatModel({ + responses: [ + formatFakeLlmResponse({ + state: 'California', + cities: ['Los Angeles', 'San Francisco'], + }), + ], + }), + inputData, + ); + + // Mock version 1.2 to test required fields behavior + mockExecuteFunctions.getNode = () => mock({ typeVersion: 1.2 }); + + const response = await node.execute.call(mockExecuteFunctions); + + expect(response).toEqual([ + [ + { + json: { + output: { + state: 'California', + cities: ['Los Angeles', 'San Francisco'], + }, + }, + }, + ], + ]); + }); + + it('should extract information using JSON schema from example - version 1.1 (optional fields)', async () => { + const node = new InformationExtractor(); + const inputData = [{ json: { text: 'John lives in California' } }]; + + const mockExecuteFunctions = createExecuteFunctionsMock( + { + text: 'John lives in California', + schemaType: 'fromJson', + jsonSchemaExample: JSON.stringify({ + state: 'California', + cities: ['Los Angeles', 'San Francisco'], + }), + options: { + systemPromptTemplate: '', + }, + }, + new FakeListChatModel({ + responses: [ + formatFakeLlmResponse({ + state: 'California', + // cities field missing - should be allowed in v1.1 + }), + ], + }), + inputData, + ); + + // Mock version 1.1 to test optional fields behavior + mockExecuteFunctions.getNode = () => mock({ typeVersion: 1.1 }); + + const response = await node.execute.call(mockExecuteFunctions); + + expect(response).toEqual([ + [ + { + json: { + output: { + state: 'California', + }, + }, + }, + ], + ]); + }); + + it('should throw error for incomplete model output in version 1.2 (required fields)', async () => { + const node = new InformationExtractor(); + const inputData = [{ json: { text: 'John lives in California' } }]; + + const mockExecuteFunctions = createExecuteFunctionsMock( + { + text: 'John lives in California', + schemaType: 'fromJson', + jsonSchemaExample: JSON.stringify({ + state: 'California', + cities: ['Los Angeles', 'San Francisco'], + zipCode: '90210', + }), + options: { + systemPromptTemplate: '', + }, + }, + new FakeListChatModel({ + responses: [ + formatFakeLlmResponse({ + state: 'California', + // Missing cities and zipCode - should fail in v1.2 since all fields are required + }), + ], + }), + inputData, + ); + + mockExecuteFunctions.getNode = () => mock({ typeVersion: 1.2 }); + + await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow(); + }); + + it('should extract information using complex nested JSON schema from example', async () => { + const node = new InformationExtractor(); + const inputData = [ + { + json: { + text: 'John Doe works at Acme Corp as a Software Engineer with 5 years experience', + }, + }, + ]; + + const complexSchema = { + person: { + name: 'John Doe', + company: { + name: 'Acme Corp', + position: 'Software Engineer', + }, + }, + experience: { + years: 5, + skills: ['JavaScript', 'TypeScript'], + }, + }; + + const mockExecuteFunctions = createExecuteFunctionsMock( + { + text: 'John Doe works at Acme Corp as a Software Engineer with 5 years experience', + schemaType: 'fromJson', + jsonSchemaExample: JSON.stringify(complexSchema), + options: { + systemPromptTemplate: '', + }, + }, + new FakeListChatModel({ + responses: [ + formatFakeLlmResponse({ + person: { + name: 'John Doe', + company: { + name: 'Acme Corp', + position: 'Software Engineer', + }, + }, + experience: { + years: 5, + skills: ['JavaScript', 'TypeScript'], + }, + }), + ], + }), + inputData, + ); + + mockExecuteFunctions.getNode = () => mock({ typeVersion: 1.2 }); + + const response = await node.execute.call(mockExecuteFunctions); + + expect(response[0][0].json.output).toMatchObject({ + person: { + name: 'John Doe', + company: { + name: 'Acme Corp', + position: 'Software Engineer', + }, + }, + experience: { + years: 5, + skills: expect.arrayContaining(['JavaScript', 'TypeScript']), + }, + }); + }); + }); + describe('Batch Processing', () => { it('should process multiple items in batches', async () => { const node = new InformationExtractor(); diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts index 5c81a2b20c..7c43b91339 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts @@ -12,12 +12,17 @@ import { } from 'n8n-workflow'; import type { z } from 'zod'; -import { inputSchemaField, jsonSchemaExampleField, schemaTypeField } from '@utils/descriptions'; +import { + buildJsonSchemaExampleNotice, + inputSchemaField, + jsonSchemaExampleField, + schemaTypeField, +} from '@utils/descriptions'; import { N8nOutputFixingParser, N8nStructuredOutputParser, } from '@utils/output_parsers/N8nOutputParser'; -import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; +import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { NAIVE_FIX_PROMPT } from './prompt'; @@ -29,8 +34,8 @@ export class OutputParserStructured implements INodeType { icon: 'fa:code', iconColor: 'black', group: ['transform'], - version: [1, 1.1, 1.2], - defaultVersion: 1.2, + version: [1, 1.1, 1.2, 1.3], + defaultVersion: 1.3, description: 'Return data in a defined JSON format', defaults: { name: 'Structured Output Parser', @@ -74,6 +79,11 @@ export class OutputParserStructured implements INodeType { "cities": ["Los Angeles", "San Francisco", "San Diego"] }`, }, + buildJsonSchemaExampleNotice({ + showExtraProps: { + '@version': [{ _cnd: { gte: 1.3 } }], + }, + }), { ...inputSchemaField, default: `{ @@ -181,6 +191,9 @@ export class OutputParserStructured implements INodeType { let inputSchema: string; + // Enforce all fields to be required in the generated schema if the node version is 1.3 or higher + const jsonExampleAllFieldsRequired = this.getNode().typeVersion >= 1.3; + if (this.getNode().typeVersion <= 1.1) { inputSchema = this.getNodeParameter('jsonSchema', itemIndex, '') as string; } else { @@ -188,7 +201,9 @@ export class OutputParserStructured implements INodeType { } const jsonSchema = - schemaType === 'fromJson' ? generateSchema(jsonExample) : jsonParse(inputSchema); + schemaType === 'fromJson' + ? generateSchemaFromExample(jsonExample, jsonExampleAllFieldsRequired) + : jsonParse(inputSchema); const zodSchema = convertJsonSchemaToZod>(jsonSchema); const nodeVersion = this.getNode().typeVersion; diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts index 2ed5db7186..b0dca4905a 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts @@ -472,6 +472,272 @@ describe('OutputParserStructured', () => { expect(parsersOutput).toEqual(outputObject); }); }); + + describe('Version 1.3', () => { + beforeEach(() => { + thisArg.getNode.mockReturnValue(mock({ typeVersion: 1.3 })); + }); + + describe('schema from JSON example', () => { + it('should make all fields required when generating schema from JSON example', async () => { + const jsonExample = `{ + "user": { + "name": "Alice", + "email": "alice@example.com", + "profile": { + "age": 30, + "city": "New York" + } + }, + "tags": ["work", "important"] + }`; + thisArg.getNodeParameter.calledWith('schemaType', 0).mockReturnValueOnce('fromJson'); + thisArg.getNodeParameter + .calledWith('jsonSchemaExample', 0) + .mockReturnValueOnce(jsonExample); + + const { response } = (await outputParser.supplyData.call(thisArg, 0)) as { + response: N8nStructuredOutputParser; + }; + + const outputObject = { + output: { + user: { + name: 'Bob', + email: 'bob@example.com', + profile: { + age: 25, + city: 'San Francisco', + }, + }, + tags: ['personal'], + }, + }; + + const parsersOutput = await response.parse(`Here's the user data: + \`\`\`json + ${JSON.stringify(outputObject)} + \`\`\` + `); + + expect(parsersOutput).toEqual(outputObject); + }); + + it('should reject output missing required fields from JSON example', async () => { + const jsonExample = `{ + "name": "Alice", + "age": 30, + "email": "alice@example.com" + }`; + thisArg.getNodeParameter.calledWith('schemaType', 0).mockReturnValueOnce('fromJson'); + thisArg.getNodeParameter + .calledWith('jsonSchemaExample', 0) + .mockReturnValueOnce(jsonExample); + + const { response } = (await outputParser.supplyData.call(thisArg, 0)) as { + response: N8nStructuredOutputParser; + }; + + const incompleteOutput = { + output: { + name: 'Bob', + age: 25, + // Missing email field + }, + }; + + await expect( + response.parse( + `Here's the incomplete output: + \`\`\`json + ${JSON.stringify(incompleteOutput)} + \`\`\` + `, + undefined, + (e) => e, + ), + ).rejects.toThrow('Required'); + }); + + it('should require all fields in array items from JSON example', async () => { + const jsonExample = `{ + "users": [ + { + "id": 1, + "name": "Alice", + "metadata": { + "department": "Engineering", + "role": "Developer" + } + } + ] + }`; + thisArg.getNodeParameter.calledWith('schemaType', 0).mockReturnValueOnce('fromJson'); + thisArg.getNodeParameter + .calledWith('jsonSchemaExample', 0) + .mockReturnValueOnce(jsonExample); + + const { response } = (await outputParser.supplyData.call(thisArg, 0)) as { + response: N8nStructuredOutputParser; + }; + + const incompleteArrayOutput = { + output: { + users: [ + { + id: 2, + name: 'Bob', + metadata: { + department: 'Marketing', + // Missing role field + }, + }, + ], + }, + }; + + await expect( + response.parse( + `Here's the incomplete array output: + \`\`\`json + ${JSON.stringify(incompleteArrayOutput)} + \`\`\` + `, + undefined, + (e) => e, + ), + ).rejects.toThrow('Required'); + }); + }); + + describe('manual schema mode', () => { + it('should work with manually defined schema in version 1.3', async () => { + const inputSchema = `{ + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "status": { "type": "string" }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "value": { "type": "string" } + }, + "required": ["id", "value"] + } + } + }, + "required": ["status", "data"] + } + }, + "required": ["result"] + }`; + thisArg.getNodeParameter.calledWith('schemaType', 0).mockReturnValueOnce('manual'); + thisArg.getNodeParameter.calledWith('inputSchema', 0).mockReturnValueOnce(inputSchema); + + const { response } = (await outputParser.supplyData.call(thisArg, 0)) as { + response: N8nStructuredOutputParser; + }; + + const outputObject = { + output: { + result: { + status: 'success', + data: [ + { id: 1, value: 'first' }, + { id: 2, value: 'second' }, + ], + }, + }, + }; + + const parsersOutput = await response.parse(`Here's the result: + \`\`\`json + ${JSON.stringify(outputObject)} + \`\`\` + `); + + expect(parsersOutput).toEqual(outputObject); + }); + }); + + describe('complex nested structures', () => { + it('should handle deeply nested objects with required fields', async () => { + const jsonExample = `{ + "company": { + "name": "TechCorp", + "departments": [ + { + "name": "Engineering", + "teams": [ + { + "name": "Backend", + "members": [ + { + "id": 1, + "name": "Alice", + "skills": ["Python", "Docker"] + } + ] + } + ] + } + ] + } + }`; + thisArg.getNodeParameter.calledWith('schemaType', 0).mockReturnValueOnce('fromJson'); + thisArg.getNodeParameter + .calledWith('jsonSchemaExample', 0) + .mockReturnValueOnce(jsonExample); + + const { response } = (await outputParser.supplyData.call(thisArg, 0)) as { + response: N8nStructuredOutputParser; + }; + + const complexOutput = { + output: { + company: { + name: 'StartupCorp', + departments: [ + { + name: 'Product', + teams: [ + { + name: 'Frontend', + members: [ + { + id: 2, + name: 'Bob', + skills: ['React', 'TypeScript'], + }, + { + id: 3, + name: 'Carol', + skills: ['Vue', 'CSS'], + }, + ], + }, + ], + }, + ], + }, + }, + }; + + const parsersOutput = await response.parse(`Here's the complex company data: + \`\`\`json + ${JSON.stringify(complexOutput)} + \`\`\` + `); + + expect(parsersOutput).toEqual(complexOutput); + }); + }); + }); }); describe('Auto-Fix', () => { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index f4de8c370f..2eeaa9698f 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -18,14 +18,28 @@ import { jsonParse, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow import { buildInputSchemaField, buildJsonSchemaExampleField, + buildJsonSchemaExampleNotice, schemaTypeField, } from '@utils/descriptions'; import { nodeNameToToolName } from '@utils/helpers'; -import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; +import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; import type { DynamicZodObject } from '../../../types/zod.types'; +const jsonSchemaExampleField = buildJsonSchemaExampleField({ + showExtraProps: { specifyInputSchema: [true] }, +}); + +const jsonSchemaExampleNotice = buildJsonSchemaExampleNotice({ + showExtraProps: { + specifyInputSchema: [true], + '@version': [{ _cnd: { gte: 1.3 } }], + }, +}); + +const jsonSchemaField = buildInputSchemaField({ showExtraProps: { specifyInputSchema: [true] } }); + export class ToolCode implements INodeType { description: INodeTypeDescription = { displayName: 'Code Tool', @@ -33,7 +47,7 @@ export class ToolCode implements INodeType { icon: 'fa:code', iconColor: 'black', group: ['transform'], - version: [1, 1.1, 1.2], + version: [1, 1.1, 1.2, 1.3], description: 'Write a tool in JS or Python', defaults: { name: 'Code Tool', @@ -173,8 +187,9 @@ export class ToolCode implements INodeType { default: false, }, { ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } }, - buildJsonSchemaExampleField({ showExtraProps: { specifyInputSchema: [true] } }), - buildInputSchemaField({ showExtraProps: { specifyInputSchema: [true] } }), + jsonSchemaExampleField, + jsonSchemaExampleNotice, + jsonSchemaField, ], }; @@ -275,9 +290,10 @@ export class ToolCode implements INodeType { const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; + const jsonSchema = schemaType === 'fromJson' - ? generateSchema(jsonExample) + ? generateSchemaFromExample(jsonExample, this.getNode().typeVersion >= 1.3) : jsonParse(inputSchema); const zodSchema = convertJsonSchemaToZod(jsonSchema); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts index 993f48e195..ecf2c69f91 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts @@ -24,7 +24,7 @@ import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow import { versionDescription } from './versionDescription'; import type { DynamicZodObject } from '../../../../types/zod.types'; -import { convertJsonSchemaToZod, generateSchema } from '../../../../utils/schemaParsing'; +import { convertJsonSchemaToZod, generateSchemaFromExample } from '../../../../utils/schemaParsing'; export class ToolWorkflowV1 implements INodeType { description: INodeTypeDescription; @@ -215,7 +215,7 @@ export class ToolWorkflowV1 implements INodeType { const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; const jsonSchema = schemaType === 'fromJson' - ? generateSchema(jsonExample) + ? generateSchemaFromExample(jsonExample) : jsonParse(inputSchema); const zodSchema = convertJsonSchemaToZod(jsonSchema); diff --git a/packages/@n8n/nodes-langchain/utils/descriptions.ts b/packages/@n8n/nodes-langchain/utils/descriptions.ts index 072473fc53..708b974858 100644 --- a/packages/@n8n/nodes-langchain/utils/descriptions.ts +++ b/packages/@n8n/nodes-langchain/utils/descriptions.ts @@ -21,6 +21,10 @@ export const schemaTypeField: INodeProperties = { description: 'How to specify the schema for the function', }; +/** + * Returns a field for inputting a JSON example that can be used to generate the schema. + * @param props + */ export const buildJsonSchemaExampleField = (props?: { showExtraProps?: Record | undefined>; }): INodeProperties => ({ @@ -43,6 +47,26 @@ export const buildJsonSchemaExampleField = (props?: { description: 'Example JSON object to use to generate the schema', }); +/** + * Returns a notice field about the generated schema properties being required by default. + * @param props + */ +export const buildJsonSchemaExampleNotice = (props?: { + showExtraProps?: Record | undefined>; +}): INodeProperties => ({ + displayName: + "All properties will be required. To make them optional, use the 'JSON Schema' schema type instead", + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + ...props?.showExtraProps, + schemaType: ['fromJson'], + }, + }, +}); + export const jsonSchemaExampleField = buildJsonSchemaExampleField(); export const buildInputSchemaField = (props?: { diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts index 641f84e5d5..24bf703d59 100644 --- a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts @@ -106,10 +106,14 @@ export class N8nStructuredOutputParser extends StructuredOutputParser< }, ), }); - } else { + } else if (nodeVersion < 1.3) { returnSchema = z.object({ output: zodSchema.optional(), }); + } else { + returnSchema = z.object({ + output: zodSchema, + }); } return new N8nStructuredOutputParser(context, returnSchema); diff --git a/packages/@n8n/nodes-langchain/utils/schemaParsing.ts b/packages/@n8n/nodes-langchain/utils/schemaParsing.ts index 592f1597c2..3f10ce0a01 100644 --- a/packages/@n8n/nodes-langchain/utils/schemaParsing.ts +++ b/packages/@n8n/nodes-langchain/utils/schemaParsing.ts @@ -6,10 +6,46 @@ import type { IExecuteFunctions } from 'n8n-workflow'; import { NodeOperationError, jsonParse } from 'n8n-workflow'; import type { z } from 'zod'; -export function generateSchema(schemaString: string): JSONSchema7 { - const parsedSchema = jsonParse(schemaString); +function makeAllPropertiesRequired(schema: JSONSchema7): JSONSchema7 { + function isPropertySchema(property: unknown): property is JSONSchema7 { + return typeof property === 'object' && property !== null && 'type' in property; + } - return generateJsonSchema(parsedSchema) as JSONSchema7; + // Handle object properties + if (schema.type === 'object' && schema.properties) { + const properties = Object.keys(schema.properties); + if (properties.length > 0) { + schema.required = properties; + } + + for (const key of properties) { + if (isPropertySchema(schema.properties[key])) { + makeAllPropertiesRequired(schema.properties[key]); + } + } + } + + // Handle arrays + if (schema.type === 'array' && schema.items && isPropertySchema(schema.items)) { + schema.items = makeAllPropertiesRequired(schema.items); + } + + return schema; +} + +export function generateSchemaFromExample( + exampleJsonString: string, + allFieldsRequired = false, +): JSONSchema7 { + const parsedExample = jsonParse(exampleJsonString); + + const schema = generateJsonSchema(parsedExample) as JSONSchema7; + + if (allFieldsRequired) { + return makeAllPropertiesRequired(schema); + } + + return schema; } export function convertJsonSchemaToZod(schema: JSONSchema7) { diff --git a/packages/@n8n/nodes-langchain/utils/tests/schemaParsing.test.ts b/packages/@n8n/nodes-langchain/utils/tests/schemaParsing.test.ts new file mode 100644 index 0000000000..fb54b4b5e2 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/tests/schemaParsing.test.ts @@ -0,0 +1,381 @@ +import type { JSONSchema7 } from 'json-schema'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import { NodeOperationError } from 'n8n-workflow'; +import type { INode, IExecuteFunctions } from 'n8n-workflow'; + +import { + generateSchemaFromExample, + convertJsonSchemaToZod, + throwIfToolSchema, +} from './../schemaParsing'; + +const mockNode: INode = { + id: '1', + name: 'Mock node', + typeVersion: 1, + type: 'n8n-nodes-base.mock', + position: [60, 760], + parameters: {}, +}; + +describe('generateSchemaFromExample', () => { + it('should generate schema from simple object', () => { + const example = JSON.stringify({ + name: 'John', + age: 30, + active: true, + }); + + const schema = generateSchemaFromExample(example); + + expect(schema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + active: { type: 'boolean' }, + }, + }); + }); + + it('should generate schema from nested object', () => { + const example = JSON.stringify({ + user: { + profile: { + name: 'Jane', + email: 'jane@example.com', + }, + preferences: { + theme: 'dark', + notifications: true, + }, + }, + }); + + const schema = generateSchemaFromExample(example); + + expect(schema).toMatchObject({ + type: 'object', + properties: { + user: { + type: 'object', + properties: { + profile: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' }, + }, + }, + preferences: { + type: 'object', + properties: { + theme: { type: 'string' }, + notifications: { type: 'boolean' }, + }, + }, + }, + }, + }, + }); + }); + + it('should generate schema from array', () => { + const example = JSON.stringify({ + items: ['apple', 'banana', 'cherry'], + numbers: [1, 2, 3], + }); + + const schema = generateSchemaFromExample(example); + + expect(schema).toMatchObject({ + type: 'object', + properties: { + items: { + type: 'array', + items: { type: 'string' }, + }, + numbers: { + type: 'array', + items: { type: 'number' }, + }, + }, + }); + }); + + it('should generate schema from complex nested structure', () => { + const example = JSON.stringify({ + metadata: { + version: '1.0.0', + tags: ['production', 'api'], + }, + data: [ + { + id: 1, + name: 'Item 1', + properties: { + color: 'red', + size: 'large', + }, + }, + ], + }); + + const schema = generateSchemaFromExample(example); + + expect(schema.type).toBe('object'); + expect(schema.properties).toHaveProperty('metadata'); + expect(schema.properties).toHaveProperty('data'); + expect((schema.properties?.data as JSONSchema7).type).toBe('array'); + expect(((schema.properties?.data as JSONSchema7).items as JSONSchema7).type).toBe('object'); + }); + + it('should handle null values', () => { + const example = JSON.stringify({ + name: 'John', + middleName: null, + age: 30, + }); + + const schema = generateSchemaFromExample(example); + + expect(schema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + middleName: { type: 'null' }, + age: { type: 'number' }, + }, + }); + }); + + it('should not require fields by default', () => { + const example = JSON.stringify({ + name: 'John', + age: 30, + }); + + const schema = generateSchemaFromExample(example); + + expect(schema.required).toBeUndefined(); + }); + + it('should make all fields required when allFieldsRequired is true', () => { + const example = JSON.stringify({ + name: 'John', + age: 30, + active: true, + }); + + const schema = generateSchemaFromExample(example, true); + + expect(schema.required).toEqual(['name', 'age', 'active']); + }); + + it('should make all nested fields required when allFieldsRequired is true', () => { + const example = JSON.stringify({ + user: { + profile: { + name: 'Jane', + email: 'jane@example.com', + }, + preferences: { + theme: 'dark', + notifications: true, + }, + }, + }); + + const schema = generateSchemaFromExample(example, true); + + expect(schema.required).toEqual(['user']); + + const userSchema = schema.properties?.user as JSONSchema7; + + expect(userSchema.required).toEqual(['profile', 'preferences']); + expect((userSchema.properties?.profile as JSONSchema7).required).toEqual(['name', 'email']); + expect((userSchema.properties?.preferences as JSONSchema7).required).toEqual([ + 'theme', + 'notifications', + ]); + + // Check the full structure + expect(schema).toMatchObject({ + type: 'object', + properties: { + user: { + type: 'object', + properties: { + profile: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' }, + }, + required: ['name', 'email'], + }, + preferences: { + type: 'object', + properties: { + theme: { type: 'string' }, + notifications: { type: 'boolean' }, + }, + required: ['theme', 'notifications'], + }, + }, + required: ['profile', 'preferences'], + }, + }, + required: ['user'], + }); + }); + + it('should handle empty object', () => { + const example = JSON.stringify({}); + + const schema = generateSchemaFromExample(example); + + expect(schema).toMatchObject({ + type: 'object', + properties: {}, + }); + }); + + it('should handle empty object with allFieldsRequired true', () => { + const example = JSON.stringify({}); + + const schema = generateSchemaFromExample(example, true); + + expect(schema).toMatchObject({ + type: 'object', + properties: {}, + }); + }); + + it('should throw error for invalid JSON', () => { + const invalidJson = '{ name: "John", age: 30 }'; // Missing quotes around property names + + expect(() => generateSchemaFromExample(invalidJson)).toThrow(); + }); + + it('should handle array of objects', () => { + const example = JSON.stringify([ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ]); + + const schema = generateSchemaFromExample(example); + + expect(schema).toMatchObject({ + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + name: { type: 'string' }, + }, + }, + }); + }); + + it('should handle array of objects with allFieldsRequired true', () => { + const example = JSON.stringify([ + { id: 1, name: 'Item 1', metadata: { tag: 'prod' } }, + { id: 2, name: 'Item 2', metadata: { tag: 'dev' } }, + ]); + + const schema = generateSchemaFromExample(example, true); + + expect(schema).toMatchObject({ + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + name: { type: 'string' }, + metadata: { + type: 'object', + properties: { + tag: { type: 'string' }, + }, + required: ['tag'], + }, + }, + required: ['id', 'name', 'metadata'], + }, + }); + }); +}); + +describe('convertJsonSchemaToZod', () => { + it('should convert simple object schema to zod', () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name'], + }; + + const zodSchema = convertJsonSchemaToZod(schema); + + expect(zodSchema).toBeDefined(); + expect(typeof zodSchema.parse).toBe('function'); + }); + + it('should convert and validate with zod schema', () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name'], + }; + + const zodSchema = convertJsonSchemaToZod(schema); + + // Valid data should pass + expect(() => zodSchema.parse({ name: 'John', age: 30 })).not.toThrow(); + expect(() => zodSchema.parse({ name: 'John' })).not.toThrow(); + + // Invalid data should throw + expect(() => zodSchema.parse({ age: 30 })).toThrow(); // Missing required name + expect(() => zodSchema.parse({ name: 'John', age: 'thirty' })).toThrow(); // Wrong type for age + }); +}); + +describe('throwIfToolSchema', () => { + it('should throw NodeOperationError for tool schema error', () => { + const ctx = createMockExecuteFunction({}, mockNode); + const error = new Error('tool input did not match expected schema'); + + expect(() => throwIfToolSchema(ctx, error)).toThrow(NodeOperationError); + expect(() => throwIfToolSchema(ctx, error)).toThrow(/tool input did not match expected schema/); + expect(() => throwIfToolSchema(ctx, error)).toThrow( + /This is most likely because some of your tools are configured to require a specific schema/, + ); + }); + + it('should not throw for non-tool schema errors', () => { + const ctx = createMockExecuteFunction({}, mockNode); + const error = new Error('Some other error'); + + expect(() => throwIfToolSchema(ctx, error)).not.toThrow(); + }); + + it('should not throw for errors without message', () => { + const ctx = createMockExecuteFunction({}, mockNode); + const error = new Error(); + + expect(() => throwIfToolSchema(ctx, error)).not.toThrow(); + }); + + it('should handle errors that are not Error instances', () => { + const ctx = createMockExecuteFunction({}, mockNode); + const error = { message: 'tool input did not match expected schema' } as Error; + + expect(() => throwIfToolSchema(ctx, error)).toThrow(NodeOperationError); + }); +});