Skip to content

Commit b126472

Browse files
feat: add JSONPathExtractor tool (#5052)
* feat: add JSONPathExtractor tool with lodash-based path extraction - Implement JSONPathExtractor tool for extracting values from JSON using path notation - Use lodash.get for robust path extraction supporting edge cases (numeric string keys, array indexing) - Add configurable error handling with returnNullOnError parameter - Include comprehensive test suite with 34 tests covering all scenarios - Support JSON strings, objects, and arrays as input * fix lint * Update pnpm-lock.yaml * fix: exclude test files from TypeScript compilation Prevents test files from being included in the dist folder which was causing "jest is not defined" errors during server startup. --------- Co-authored-by: Henry Heng <[email protected]>
1 parent 4ce0851 commit b126472

File tree

7 files changed

+39199
-38594
lines changed

7 files changed

+39199
-38594
lines changed

packages/components/jest.config.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
roots: ['<rootDir>/nodes'],
5+
transform: {
6+
'^.+\\.tsx?$': 'ts-jest'
7+
},
8+
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
9+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
10+
verbose: true,
11+
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
12+
moduleNameMapper: {
13+
'^../../../src/(.*)$': '<rootDir>/src/$1'
14+
}
15+
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
const { nodeClass: JSONPathExtractor_Tools } = require('./JSONPathExtractor')
2+
import { INodeData } from '../../../src/Interface'
3+
4+
// Mock the getBaseClasses function
5+
jest.mock('../../../src/utils', () => ({
6+
getBaseClasses: jest.fn(() => ['Tool', 'StructuredTool'])
7+
}))
8+
9+
// Helper function to create a valid INodeData object
10+
function createNodeData(id: string, inputs: any): INodeData {
11+
return {
12+
id: id,
13+
label: 'JSON Path Extractor',
14+
name: 'jsonPathExtractor',
15+
type: 'JSONPathExtractor',
16+
icon: 'jsonpathextractor.svg',
17+
version: 1.0,
18+
category: 'Tools',
19+
baseClasses: ['JSONPathExtractor', 'Tool'],
20+
inputs: inputs
21+
}
22+
}
23+
24+
describe('JSONPathExtractor', () => {
25+
let nodeClass: any
26+
27+
beforeEach(() => {
28+
nodeClass = new JSONPathExtractor_Tools()
29+
})
30+
31+
describe('Tool Initialization', () => {
32+
it('should throw error when path is not provided', async () => {
33+
const nodeData = createNodeData('test-node-1', {
34+
path: ''
35+
})
36+
37+
await expect(nodeClass.init(nodeData, '')).rejects.toThrow('JSON Path is required')
38+
})
39+
40+
it('should initialize tool with path and default returnNullOnError', async () => {
41+
const nodeData = createNodeData('test-node-2', {
42+
path: 'data.value'
43+
})
44+
45+
const tool = await nodeClass.init(nodeData, '')
46+
expect(tool).toBeDefined()
47+
expect(tool.name).toBe('json_path_extractor')
48+
})
49+
50+
it('should initialize tool with custom returnNullOnError', async () => {
51+
const nodeData = createNodeData('test-node-3', {
52+
path: 'data.value',
53+
returnNullOnError: true
54+
})
55+
56+
const tool = await nodeClass.init(nodeData, '')
57+
expect(tool).toBeDefined()
58+
})
59+
})
60+
61+
describe('JSONPathExtractorTool Functionality', () => {
62+
describe('Positive test cases - Path extraction', () => {
63+
const successCases = [
64+
{
65+
name: 'simple path from object',
66+
path: 'data.value',
67+
input: { data: { value: 'test' } },
68+
expected: 'test'
69+
},
70+
{
71+
name: 'nested path from object',
72+
path: 'user.profile.name',
73+
input: { user: { profile: { name: 'John' } } },
74+
expected: 'John'
75+
},
76+
{
77+
name: 'array index access',
78+
path: 'items[0].name',
79+
input: { items: [{ name: 'first' }, { name: 'second' }] },
80+
expected: 'first'
81+
},
82+
{
83+
name: 'multi-dimensional array',
84+
path: 'matrix[0][1]',
85+
input: {
86+
matrix: [
87+
['a', 'b'],
88+
['c', 'd']
89+
]
90+
},
91+
expected: 'b'
92+
},
93+
{
94+
name: 'object return (stringified)',
95+
path: 'data',
96+
input: { data: { nested: 'object' } },
97+
expected: '{"nested":"object"}'
98+
},
99+
{
100+
name: 'array return (stringified)',
101+
path: 'tags',
102+
input: { tags: ['a', 'b', 'c'] },
103+
expected: '["a","b","c"]'
104+
},
105+
{
106+
name: 'deep nesting',
107+
path: 'a.b.c.d.e',
108+
input: { a: { b: { c: { d: { e: 'deep' } } } } },
109+
expected: 'deep'
110+
},
111+
{
112+
name: 'array at root with index',
113+
path: '[1]',
114+
input: ['first', 'second', 'third'],
115+
expected: 'second'
116+
}
117+
]
118+
119+
test.each(successCases)('should extract $name', async ({ path, input, expected }) => {
120+
const nodeData = createNodeData(`test-node-${path}`, {
121+
path: path,
122+
returnNullOnError: false
123+
})
124+
const tool = await nodeClass.init(nodeData, '')
125+
const result = await tool._call({ json: input })
126+
expect(result).toBe(expected)
127+
})
128+
})
129+
130+
describe('Primitive value handling', () => {
131+
const primitiveTests = [
132+
{ name: 'string', path: 'val', input: { val: 'text' }, expected: 'text' },
133+
{ name: 'number', path: 'val', input: { val: 42 }, expected: '42' },
134+
{ name: 'zero', path: 'val', input: { val: 0 }, expected: '0' },
135+
{ name: 'boolean true', path: 'val', input: { val: true }, expected: 'true' },
136+
{ name: 'boolean false', path: 'val', input: { val: false }, expected: 'false' },
137+
{ name: 'null', path: 'val', input: { val: null }, expected: 'null' },
138+
{ name: 'empty string', path: 'val', input: { val: '' }, expected: '' }
139+
]
140+
141+
test.each(primitiveTests)('should handle $name value', async ({ path, input, expected }) => {
142+
const nodeData = createNodeData(`test-primitive`, {
143+
path: path,
144+
returnNullOnError: false
145+
})
146+
const tool = await nodeClass.init(nodeData, '')
147+
const result = await tool._call({ json: input })
148+
expect(result).toBe(expected)
149+
})
150+
})
151+
152+
describe('Special characters in keys', () => {
153+
const specialCharTests = [
154+
{ name: 'dashes', path: 'data.key-with-dash', input: { data: { 'key-with-dash': 'value' } } },
155+
{ name: 'spaces', path: 'data.key with spaces', input: { data: { 'key with spaces': 'value' } } },
156+
{ name: 'unicode', path: 'data.emoji🔑', input: { data: { 'emoji🔑': 'value' } } },
157+
{ name: 'numeric strings', path: 'data.123', input: { data: { '123': 'value' } } }
158+
]
159+
160+
test.each(specialCharTests)('should handle $name in keys', async ({ path, input }) => {
161+
const nodeData = createNodeData(`test-special`, {
162+
path: path,
163+
returnNullOnError: false
164+
})
165+
const tool = await nodeClass.init(nodeData, '')
166+
const result = await tool._call({ json: input })
167+
expect(result).toBe('value')
168+
})
169+
})
170+
171+
describe('Error handling - throw mode', () => {
172+
const errorCases = [
173+
{
174+
name: 'path not found',
175+
path: 'data.value',
176+
input: { data: { other: 'value' } },
177+
errorPattern: /Path "data.value" not found in JSON/
178+
},
179+
{
180+
name: 'invalid JSON string',
181+
path: 'data',
182+
input: 'invalid json',
183+
errorPattern: /Invalid JSON string/
184+
},
185+
{
186+
name: 'array index on object',
187+
path: 'data[0]',
188+
input: { data: { key: 'value' } },
189+
errorPattern: /Path "data\[0\]" not found in JSON/
190+
},
191+
{
192+
name: 'out of bounds array',
193+
path: 'items[10]',
194+
input: { items: ['a', 'b'] },
195+
errorPattern: /Path "items\[10\]" not found in JSON/
196+
}
197+
]
198+
199+
test.each(errorCases)('should throw error for $name', async ({ path, input, errorPattern }) => {
200+
const nodeData = createNodeData(`test-error`, {
201+
path: path,
202+
returnNullOnError: false
203+
})
204+
const tool = await nodeClass.init(nodeData, '')
205+
await expect(tool._call({ json: input })).rejects.toThrow(errorPattern)
206+
})
207+
})
208+
209+
describe('Error handling - null mode', () => {
210+
const nullCases = [
211+
{ name: 'path not found', path: 'missing.path', input: { data: 'value' } },
212+
{ name: 'invalid JSON string', path: 'data', input: 'invalid json' },
213+
{ name: 'null in path', path: 'data.nested.value', input: { data: { nested: null } } },
214+
{ name: 'empty array access', path: 'items[0]', input: { items: [] } },
215+
{ name: 'property on primitive', path: 'value.nested', input: { value: 'string' } }
216+
]
217+
218+
test.each(nullCases)('should return null for $name', async ({ path, input }) => {
219+
const nodeData = createNodeData(`test-null`, {
220+
path: path,
221+
returnNullOnError: true
222+
})
223+
const tool = await nodeClass.init(nodeData, '')
224+
const result = await tool._call({ json: input })
225+
expect(result).toBe('null')
226+
})
227+
228+
it('should still extract valid paths when returnNullOnError is true', async () => {
229+
const nodeData = createNodeData('test-valid-null-mode', {
230+
path: 'data.value',
231+
returnNullOnError: true
232+
})
233+
const tool = await nodeClass.init(nodeData, '')
234+
const result = await tool._call({
235+
json: { data: { value: 'test' } }
236+
})
237+
expect(result).toBe('test')
238+
})
239+
})
240+
241+
describe('Complex structures', () => {
242+
it('should handle deeply nested arrays and objects', async () => {
243+
const nodeData = createNodeData('test-complex', {
244+
path: 'users[0].addresses[1].city',
245+
returnNullOnError: false
246+
})
247+
const tool = await nodeClass.init(nodeData, '')
248+
const result = await tool._call({
249+
json: {
250+
users: [
251+
{
252+
addresses: [{ city: 'New York' }, { city: 'Los Angeles' }]
253+
}
254+
]
255+
}
256+
})
257+
expect(result).toBe('Los Angeles')
258+
})
259+
})
260+
})
261+
})

0 commit comments

Comments
 (0)