mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(Structured Output Parser Node): Mark all parameters as required for schemas generated from JSON example (#15935)
This commit is contained in:
@@ -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<string, Array<NodeParameterValue | DisplayCondition> | 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<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 buildInputSchemaField = (props?: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SchemaObject>(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<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) {
|
||||
|
||||
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