mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(Structured Output Parser Node): Mark all parameters as required for schemas generated from JSON example (#15935)
This commit is contained in:
@@ -11,8 +11,13 @@ import type {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { inputSchemaField, jsonSchemaExampleField, schemaTypeField } from '@utils/descriptions';
|
import {
|
||||||
import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing';
|
buildJsonSchemaExampleNotice,
|
||||||
|
inputSchemaField,
|
||||||
|
jsonSchemaExampleField,
|
||||||
|
schemaTypeField,
|
||||||
|
} from '@utils/descriptions';
|
||||||
|
import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing';
|
||||||
import { getBatchingOptionFields } from '@utils/sharedFields';
|
import { getBatchingOptionFields } from '@utils/sharedFields';
|
||||||
|
|
||||||
import { SYSTEM_PROMPT_TEMPLATE } from './constants';
|
import { SYSTEM_PROMPT_TEMPLATE } from './constants';
|
||||||
@@ -27,7 +32,8 @@ export class InformationExtractor implements INodeType {
|
|||||||
icon: 'fa:project-diagram',
|
icon: 'fa:project-diagram',
|
||||||
iconColor: 'black',
|
iconColor: 'black',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1],
|
version: [1, 1.1, 1.2],
|
||||||
|
defaultVersion: 1.2,
|
||||||
description: 'Extract information from text in a structured format',
|
description: 'Extract information from text in a structured format',
|
||||||
codex: {
|
codex: {
|
||||||
alias: ['NER', 'parse', 'parsing', 'JSON', 'data extraction', 'structured'],
|
alias: ['NER', 'parse', 'parsing', 'JSON', 'data extraction', 'structured'],
|
||||||
@@ -88,6 +94,11 @@ export class InformationExtractor implements INodeType {
|
|||||||
"cities": ["Los Angeles", "San Francisco", "San Diego"]
|
"cities": ["Los Angeles", "San Francisco", "San Diego"]
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
|
buildJsonSchemaExampleNotice({
|
||||||
|
showExtraProps: {
|
||||||
|
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
...inputSchemaField,
|
...inputSchemaField,
|
||||||
default: `{
|
default: `{
|
||||||
@@ -242,7 +253,10 @@ export class InformationExtractor implements INodeType {
|
|||||||
|
|
||||||
if (schemaType === 'fromJson') {
|
if (schemaType === 'fromJson') {
|
||||||
const jsonExample = this.getNodeParameter('jsonSchemaExample', 0, '') as string;
|
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 {
|
} else {
|
||||||
const inputSchema = this.getNodeParameter('inputSchema', 0, '') as string;
|
const inputSchema = this.getNodeParameter('inputSchema', 0, '') as string;
|
||||||
jsonSchema = jsonParse<JSONSchema7>(inputSchema);
|
jsonSchema = jsonParse<JSONSchema7>(inputSchema);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||||
import { FakeListChatModel } from '@langchain/core/utils/testing';
|
import { FakeListChatModel } from '@langchain/core/utils/testing';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
import get from 'lodash/get';
|
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 { makeZodSchemaFromAttributes } from '../helpers';
|
||||||
import { InformationExtractor } from '../InformationExtractor.node';
|
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<INode>({ 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<INode>({ 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<INode>({ 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<INode>({ 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', () => {
|
describe('Batch Processing', () => {
|
||||||
it('should process multiple items in batches', async () => {
|
it('should process multiple items in batches', async () => {
|
||||||
const node = new InformationExtractor();
|
const node = new InformationExtractor();
|
||||||
|
|||||||
@@ -12,12 +12,17 @@ import {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { inputSchemaField, jsonSchemaExampleField, schemaTypeField } from '@utils/descriptions';
|
import {
|
||||||
|
buildJsonSchemaExampleNotice,
|
||||||
|
inputSchemaField,
|
||||||
|
jsonSchemaExampleField,
|
||||||
|
schemaTypeField,
|
||||||
|
} from '@utils/descriptions';
|
||||||
import {
|
import {
|
||||||
N8nOutputFixingParser,
|
N8nOutputFixingParser,
|
||||||
N8nStructuredOutputParser,
|
N8nStructuredOutputParser,
|
||||||
} from '@utils/output_parsers/N8nOutputParser';
|
} from '@utils/output_parsers/N8nOutputParser';
|
||||||
import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing';
|
import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing';
|
||||||
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
||||||
|
|
||||||
import { NAIVE_FIX_PROMPT } from './prompt';
|
import { NAIVE_FIX_PROMPT } from './prompt';
|
||||||
@@ -29,8 +34,8 @@ export class OutputParserStructured implements INodeType {
|
|||||||
icon: 'fa:code',
|
icon: 'fa:code',
|
||||||
iconColor: 'black',
|
iconColor: 'black',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2],
|
version: [1, 1.1, 1.2, 1.3],
|
||||||
defaultVersion: 1.2,
|
defaultVersion: 1.3,
|
||||||
description: 'Return data in a defined JSON format',
|
description: 'Return data in a defined JSON format',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Structured Output Parser',
|
name: 'Structured Output Parser',
|
||||||
@@ -74,6 +79,11 @@ export class OutputParserStructured implements INodeType {
|
|||||||
"cities": ["Los Angeles", "San Francisco", "San Diego"]
|
"cities": ["Los Angeles", "San Francisco", "San Diego"]
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
|
buildJsonSchemaExampleNotice({
|
||||||
|
showExtraProps: {
|
||||||
|
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
...inputSchemaField,
|
...inputSchemaField,
|
||||||
default: `{
|
default: `{
|
||||||
@@ -181,6 +191,9 @@ export class OutputParserStructured implements INodeType {
|
|||||||
|
|
||||||
let inputSchema: string;
|
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) {
|
if (this.getNode().typeVersion <= 1.1) {
|
||||||
inputSchema = this.getNodeParameter('jsonSchema', itemIndex, '') as string;
|
inputSchema = this.getNodeParameter('jsonSchema', itemIndex, '') as string;
|
||||||
} else {
|
} else {
|
||||||
@@ -188,7 +201,9 @@ export class OutputParserStructured implements INodeType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jsonSchema =
|
const jsonSchema =
|
||||||
schemaType === 'fromJson' ? generateSchema(jsonExample) : jsonParse<JSONSchema7>(inputSchema);
|
schemaType === 'fromJson'
|
||||||
|
? generateSchemaFromExample(jsonExample, jsonExampleAllFieldsRequired)
|
||||||
|
: jsonParse<JSONSchema7>(inputSchema);
|
||||||
|
|
||||||
const zodSchema = convertJsonSchemaToZod<z.ZodSchema<object>>(jsonSchema);
|
const zodSchema = convertJsonSchemaToZod<z.ZodSchema<object>>(jsonSchema);
|
||||||
const nodeVersion = this.getNode().typeVersion;
|
const nodeVersion = this.getNode().typeVersion;
|
||||||
|
|||||||
@@ -472,6 +472,272 @@ describe('OutputParserStructured', () => {
|
|||||||
expect(parsersOutput).toEqual(outputObject);
|
expect(parsersOutput).toEqual(outputObject);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Version 1.3', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
thisArg.getNode.mockReturnValue(mock<INode>({ 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', () => {
|
describe('Auto-Fix', () => {
|
||||||
|
|||||||
@@ -18,14 +18,28 @@ import { jsonParse, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow
|
|||||||
import {
|
import {
|
||||||
buildInputSchemaField,
|
buildInputSchemaField,
|
||||||
buildJsonSchemaExampleField,
|
buildJsonSchemaExampleField,
|
||||||
|
buildJsonSchemaExampleNotice,
|
||||||
schemaTypeField,
|
schemaTypeField,
|
||||||
} from '@utils/descriptions';
|
} from '@utils/descriptions';
|
||||||
import { nodeNameToToolName } from '@utils/helpers';
|
import { nodeNameToToolName } from '@utils/helpers';
|
||||||
import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing';
|
import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing';
|
||||||
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
||||||
|
|
||||||
import type { DynamicZodObject } from '../../../types/zod.types';
|
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 {
|
export class ToolCode implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
displayName: 'Code Tool',
|
displayName: 'Code Tool',
|
||||||
@@ -33,7 +47,7 @@ export class ToolCode implements INodeType {
|
|||||||
icon: 'fa:code',
|
icon: 'fa:code',
|
||||||
iconColor: 'black',
|
iconColor: 'black',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2],
|
version: [1, 1.1, 1.2, 1.3],
|
||||||
description: 'Write a tool in JS or Python',
|
description: 'Write a tool in JS or Python',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Code Tool',
|
name: 'Code Tool',
|
||||||
@@ -173,8 +187,9 @@ export class ToolCode implements INodeType {
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{ ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } },
|
{ ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } },
|
||||||
buildJsonSchemaExampleField({ showExtraProps: { specifyInputSchema: [true] } }),
|
jsonSchemaExampleField,
|
||||||
buildInputSchemaField({ showExtraProps: { specifyInputSchema: [true] } }),
|
jsonSchemaExampleNotice,
|
||||||
|
jsonSchemaField,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,9 +290,10 @@ export class ToolCode implements INodeType {
|
|||||||
const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string;
|
const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string;
|
||||||
|
|
||||||
const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
|
const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
|
||||||
|
|
||||||
const jsonSchema =
|
const jsonSchema =
|
||||||
schemaType === 'fromJson'
|
schemaType === 'fromJson'
|
||||||
? generateSchema(jsonExample)
|
? generateSchemaFromExample(jsonExample, this.getNode().typeVersion >= 1.3)
|
||||||
: jsonParse<JSONSchema7>(inputSchema);
|
: jsonParse<JSONSchema7>(inputSchema);
|
||||||
|
|
||||||
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
|
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow
|
|||||||
|
|
||||||
import { versionDescription } from './versionDescription';
|
import { versionDescription } from './versionDescription';
|
||||||
import type { DynamicZodObject } from '../../../../types/zod.types';
|
import type { DynamicZodObject } from '../../../../types/zod.types';
|
||||||
import { convertJsonSchemaToZod, generateSchema } from '../../../../utils/schemaParsing';
|
import { convertJsonSchemaToZod, generateSchemaFromExample } from '../../../../utils/schemaParsing';
|
||||||
|
|
||||||
export class ToolWorkflowV1 implements INodeType {
|
export class ToolWorkflowV1 implements INodeType {
|
||||||
description: INodeTypeDescription;
|
description: INodeTypeDescription;
|
||||||
@@ -215,7 +215,7 @@ export class ToolWorkflowV1 implements INodeType {
|
|||||||
const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
|
const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
|
||||||
const jsonSchema =
|
const jsonSchema =
|
||||||
schemaType === 'fromJson'
|
schemaType === 'fromJson'
|
||||||
? generateSchema(jsonExample)
|
? generateSchemaFromExample(jsonExample)
|
||||||
: jsonParse<JSONSchema7>(inputSchema);
|
: jsonParse<JSONSchema7>(inputSchema);
|
||||||
|
|
||||||
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
|
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const schemaTypeField: INodeProperties = {
|
|||||||
description: 'How to specify the schema for the function',
|
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?: {
|
export const buildJsonSchemaExampleField = (props?: {
|
||||||
showExtraProps?: Record<string, Array<NodeParameterValue | DisplayCondition> | undefined>;
|
showExtraProps?: Record<string, Array<NodeParameterValue | DisplayCondition> | undefined>;
|
||||||
}): INodeProperties => ({
|
}): INodeProperties => ({
|
||||||
@@ -43,6 +47,26 @@ export const buildJsonSchemaExampleField = (props?: {
|
|||||||
description: 'Example JSON object to use to generate the schema',
|
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<string, Array<NodeParameterValue | DisplayCondition> | 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 jsonSchemaExampleField = buildJsonSchemaExampleField();
|
||||||
|
|
||||||
export const buildInputSchemaField = (props?: {
|
export const buildInputSchemaField = (props?: {
|
||||||
|
|||||||
@@ -106,10 +106,14 @@ export class N8nStructuredOutputParser extends StructuredOutputParser<
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
} else {
|
} else if (nodeVersion < 1.3) {
|
||||||
returnSchema = z.object({
|
returnSchema = z.object({
|
||||||
output: zodSchema.optional(),
|
output: zodSchema.optional(),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
returnSchema = z.object({
|
||||||
|
output: zodSchema,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new N8nStructuredOutputParser(context, returnSchema);
|
return new N8nStructuredOutputParser(context, returnSchema);
|
||||||
|
|||||||
@@ -6,10 +6,46 @@ import type { IExecuteFunctions } from 'n8n-workflow';
|
|||||||
import { NodeOperationError, jsonParse } from 'n8n-workflow';
|
import { NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
export function generateSchema(schemaString: string): JSONSchema7 {
|
function makeAllPropertiesRequired(schema: JSONSchema7): JSONSchema7 {
|
||||||
const parsedSchema = jsonParse<SchemaObject>(schemaString);
|
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<SchemaObject>(exampleJsonString);
|
||||||
|
|
||||||
|
const schema = generateJsonSchema(parsedExample) as JSONSchema7;
|
||||||
|
|
||||||
|
if (allFieldsRequired) {
|
||||||
|
return makeAllPropertiesRequired(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertJsonSchemaToZod<T extends z.ZodTypeAny = z.ZodTypeAny>(schema: JSONSchema7) {
|
export function convertJsonSchemaToZod<T extends z.ZodTypeAny = z.ZodTypeAny>(schema: JSONSchema7) {
|
||||||
|
|||||||
381
packages/@n8n/nodes-langchain/utils/tests/schemaParsing.test.ts
Normal file
381
packages/@n8n/nodes-langchain/utils/tests/schemaParsing.test.ts
Normal file
@@ -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<IExecuteFunctions>({}, 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<IExecuteFunctions>({}, 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<IExecuteFunctions>({}, mockNode);
|
||||||
|
const error = new Error();
|
||||||
|
|
||||||
|
expect(() => throwIfToolSchema(ctx, error)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors that are not Error instances', () => {
|
||||||
|
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
|
||||||
|
const error = { message: 'tool input did not match expected schema' } as Error;
|
||||||
|
|
||||||
|
expect(() => throwIfToolSchema(ctx, error)).toThrow(NodeOperationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user