refactor(core): Move more code out of NodeExecutionFunctions, and add additional tests (no-changelog) (#13195)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-02-11 17:51:50 +01:00 committed by GitHub
parent f03e5e7d22
commit 90d6f0020c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 3291 additions and 2508 deletions

View file

@ -1,314 +0,0 @@
import { mock } from 'jest-mock-extended';
import type {
IHttpRequestMethods,
IHttpRequestOptions,
INode,
IRequestOptions,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import nock from 'nock';
import type { ExecutionLifecycleHooks } from '@/execution-engine/execution-lifecycle-hooks';
import {
copyInputItems,
invokeAxios,
proxyRequestToAxios,
removeEmptyBody,
} from '@/node-execute-functions';
describe('NodeExecuteFunctions', () => {
describe('proxyRequestToAxios', () => {
const baseUrl = 'http://example.de';
const workflow = mock<Workflow>();
const hooks = mock<ExecutionLifecycleHooks>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ hooks });
const node = mock<INode>();
beforeEach(() => {
hooks.runHook.mockClear();
});
test('should rethrow an error with `status` property', async () => {
nock(baseUrl).get('/test').reply(400);
try {
await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`);
} catch (error) {
expect(error.status).toEqual(400);
}
});
test('should not throw if the response status is 200', async () => {
nock(baseUrl).get('/test').reply(200);
await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`);
expect(hooks.runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
test('should throw if the response status is 403', async () => {
const headers = { 'content-type': 'text/plain' };
nock(baseUrl).get('/test').reply(403, 'Forbidden', headers);
try {
await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`);
} catch (error) {
expect(error.statusCode).toEqual(403);
expect(error.request).toBeUndefined();
expect(error.response).toMatchObject({ headers, status: 403 });
expect(error.options).toMatchObject({
headers: { Accept: '*/*' },
method: 'get',
url: 'http://example.de/test',
});
expect(error.config).toBeUndefined();
expect(error.message).toEqual('403 - "Forbidden"');
}
expect(hooks.runHook).not.toHaveBeenCalled();
});
test('should not throw if the response status is 404, but `simple` option is set to `false`', async () => {
nock(baseUrl).get('/test').reply(404, 'Not Found');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/test`,
simple: false,
});
expect(response).toEqual('Not Found');
expect(hooks.runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
test('should return full response when `resolveWithFullResponse` is set to true', async () => {
nock(baseUrl).get('/test').reply(404, 'Not Found');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/test`,
resolveWithFullResponse: true,
simple: false,
});
expect(response).toMatchObject({
body: 'Not Found',
headers: {},
statusCode: 404,
statusMessage: 'Not Found',
});
expect(hooks.runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
describe('redirects', () => {
test('should forward authorization header', async () => {
nock(baseUrl).get('/redirect').reply(301, '', { Location: 'https://otherdomain.com/test' });
nock('https://otherdomain.com')
.get('/test')
.reply(200, function () {
return this.req.headers;
});
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
auth: {
username: 'testuser',
password: 'testpassword',
},
headers: {
'X-Other-Header': 'otherHeaderContent',
},
resolveWithFullResponse: true,
});
expect(response.statusCode).toBe(200);
const forwardedHeaders = JSON.parse(response.body);
expect(forwardedHeaders.authorization).toBe('Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk');
expect(forwardedHeaders['x-other-header']).toBe('otherHeaderContent');
});
test('should follow redirects by default', async () => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
resolveWithFullResponse: true,
});
expect(response).toMatchObject({
body: 'Redirected',
headers: {},
statusCode: 200,
});
});
test('should not follow redirects when configured', async () => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
await expect(
proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
resolveWithFullResponse: true,
followRedirect: false,
}),
).rejects.toThrowError(expect.objectContaining({ statusCode: 301 }));
});
});
});
describe('invokeAxios', () => {
const baseUrl = 'http://example.de';
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
it('should throw error for non-401 status codes', async () => {
nock(baseUrl).get('/test').reply(500, {});
await expect(invokeAxios({ url: `${baseUrl}/test` })).rejects.toThrow(
'Request failed with status code 500',
);
});
it('should throw error on 401 without digest auth challenge', async () => {
nock(baseUrl).get('/test').reply(401, {});
await expect(
invokeAxios(
{
url: `${baseUrl}/test`,
},
{ sendImmediately: false },
),
).rejects.toThrow('Request failed with status code 401');
});
it('should make successful requests', async () => {
nock(baseUrl).get('/test').reply(200, { success: true });
const response = await invokeAxios({
url: `${baseUrl}/test`,
});
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
it('should handle digest auth when receiving 401 with nonce', async () => {
nock(baseUrl)
.get('/test')
.matchHeader('authorization', 'Basic dXNlcjpwYXNz')
.once()
.reply(401, {}, { 'www-authenticate': 'Digest realm="test", nonce="abc123", qop="auth"' });
nock(baseUrl)
.get('/test')
.matchHeader(
'authorization',
/^Digest username="user",realm="test",nonce="abc123",uri="\/test",qop="auth",algorithm="MD5",response="[0-9a-f]{32}"/,
)
.reply(200, { success: true });
const response = await invokeAxios(
{
url: `${baseUrl}/test`,
auth: {
username: 'user',
password: 'pass',
},
},
{ sendImmediately: false },
);
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
});
describe('copyInputItems', () => {
it('should pick only selected properties', () => {
const output = copyInputItems(
[
{
json: {
a: 1,
b: true,
c: {},
},
},
],
['a'],
);
expect(output).toEqual([{ a: 1 }]);
});
it('should convert undefined to null', () => {
const output = copyInputItems(
[
{
json: {
a: undefined,
},
},
],
['a'],
);
expect(output).toEqual([{ a: null }]);
});
it('should clone objects', () => {
const input = {
a: { b: 5 },
};
const output = copyInputItems(
[
{
json: input,
},
],
['a'],
);
expect(output[0].a).toEqual(input.a);
expect(output[0].a === input.a).toEqual(false);
});
});
describe('removeEmptyBody', () => {
test.each(['GET', 'HEAD', 'OPTIONS'] as IHttpRequestMethods[])(
'Should remove empty body for %s',
async (method) => {
const requestOptions = {
method,
body: {},
} as IHttpRequestOptions | IRequestOptions;
removeEmptyBody(requestOptions);
expect(requestOptions.body).toEqual(undefined);
},
);
test.each(['GET', 'HEAD', 'OPTIONS'] as IHttpRequestMethods[])(
'Should not remove non-empty body for %s',
async (method) => {
const requestOptions = {
method,
body: { test: true },
} as IHttpRequestOptions | IRequestOptions;
removeEmptyBody(requestOptions);
expect(requestOptions.body).toEqual({ test: true });
},
);
test.each(['POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'Should not remove empty body for %s',
async (method) => {
const requestOptions = {
method,
body: {},
} as IHttpRequestOptions | IRequestOptions;
removeEmptyBody(requestOptions);
expect(requestOptions.body).toEqual({});
},
);
});
});

View file

@ -3,8 +3,9 @@ import type { ICredentialTestFunctions } from 'n8n-workflow';
import { Memoized } from '@/decorators'; import { Memoized } from '@/decorators';
import { Logger } from '@/logging'; import { Logger } from '@/logging';
// eslint-disable-next-line import/no-cycle
import { getSSHTunnelFunctions, proxyRequestToAxios } from '@/node-execute-functions'; import { proxyRequestToAxios } from './utils/request-helper-functions';
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
export class CredentialTestContext implements ICredentialTestFunctions { export class CredentialTestContext implements ICredentialTestFunctions {
readonly helpers: ICredentialTestFunctions['helpers']; readonly helpers: ICredentialTestFunctions['helpers'];

View file

@ -23,16 +23,6 @@ import {
NodeConnectionType, NodeConnectionType,
} from 'n8n-workflow'; } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
returnJsonArray,
copyInputItems,
normalizeItems,
constructExecutionMetaData,
getRequestHelperFunctions,
getSSHTunnelFunctions,
} from '@/node-execute-functions';
import { BaseExecuteContext } from './base-execute-context'; import { BaseExecuteContext } from './base-execute-context';
import { import {
assertBinaryData, assertBinaryData,
@ -41,9 +31,15 @@ import {
getBinaryHelperFunctions, getBinaryHelperFunctions,
detectBinaryEncoding, detectBinaryEncoding,
} from './utils/binary-helper-functions'; } from './utils/binary-helper-functions';
import { constructExecutionMetaData } from './utils/construct-execution-metadata';
import { copyInputItems } from './utils/copy-input-items';
import { getDeduplicationHelperFunctions } from './utils/deduplication-helper-functions'; import { getDeduplicationHelperFunctions } from './utils/deduplication-helper-functions';
import { getFileSystemHelperFunctions } from './utils/file-system-helper-functions'; import { getFileSystemHelperFunctions } from './utils/file-system-helper-functions';
import { getInputConnectionData } from './utils/get-input-connection-data'; import { getInputConnectionData } from './utils/get-input-connection-data';
import { normalizeItems } from './utils/normalize-items';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { returnJsonArray } from './utils/return-json-array';
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions { export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions {
readonly helpers: IExecuteFunctions['helpers']; readonly helpers: IExecuteFunctions['helpers'];

View file

@ -13,9 +13,6 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError, createDeferredPromise, NodeConnectionType } from 'n8n-workflow'; import { ApplicationError, createDeferredPromise, NodeConnectionType } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import { getRequestHelperFunctions, returnJsonArray } from '@/node-execute-functions';
import { BaseExecuteContext } from './base-execute-context'; import { BaseExecuteContext } from './base-execute-context';
import { import {
assertBinaryData, assertBinaryData,
@ -23,6 +20,8 @@ import {
getBinaryDataBuffer, getBinaryDataBuffer,
getBinaryHelperFunctions, getBinaryHelperFunctions,
} from './utils/binary-helper-functions'; } from './utils/binary-helper-functions';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { returnJsonArray } from './utils/return-json-array';
export class ExecuteSingleContext extends BaseExecuteContext implements IExecuteSingleFunctions { export class ExecuteSingleContext extends BaseExecuteContext implements IExecuteSingleFunctions {
readonly helpers: IExecuteSingleFunctions['helpers']; readonly helpers: IExecuteSingleFunctions['helpers'];

View file

@ -11,14 +11,9 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getNodeWebhookUrl,
getRequestHelperFunctions,
getWebhookDescription,
} from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context'; import { NodeExecutionContext } from './node-execution-context';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { getNodeWebhookUrl, getWebhookDescription } from './utils/webhook-helper-functions';
export class HookContext extends NodeExecutionContext implements IHookFunctions { export class HookContext extends NodeExecutionContext implements IHookFunctions {
readonly helpers: IHookFunctions['helpers']; readonly helpers: IHookFunctions['helpers'];

View file

@ -1,18 +1,18 @@
// eslint-disable-next-line import/no-cycle
export { CredentialTestContext } from './credentials-test-context'; export { CredentialTestContext } from './credentials-test-context';
// eslint-disable-next-line import/no-cycle
export { ExecuteContext } from './execute-context'; export { ExecuteContext } from './execute-context';
export { ExecuteSingleContext } from './execute-single-context'; export { ExecuteSingleContext } from './execute-single-context';
export { HookContext } from './hook-context'; export { HookContext } from './hook-context';
export { LoadOptionsContext } from './load-options-context'; export { LoadOptionsContext } from './load-options-context';
export { LocalLoadOptionsContext } from './local-load-options-context'; export { LocalLoadOptionsContext } from './local-load-options-context';
export { PollContext } from './poll-context'; export { PollContext } from './poll-context';
// eslint-disable-next-line import/no-cycle
export { SupplyDataContext } from './supply-data-context'; export { SupplyDataContext } from './supply-data-context';
export { TriggerContext } from './trigger-context'; export { TriggerContext } from './trigger-context';
export { WebhookContext } from './webhook-context'; export { WebhookContext } from './webhook-context';
export { constructExecutionMetaData } from './utils/construct-execution-metadata';
export { getAdditionalKeys } from './utils/get-additional-keys'; export { getAdditionalKeys } from './utils/get-additional-keys';
export { normalizeItems } from './utils/normalize-items';
export { parseIncomingMessage } from './utils/parse-incoming-message'; export { parseIncomingMessage } from './utils/parse-incoming-message';
export { parseRequestObject } from './utils/parse-request-object'; export { parseRequestObject } from './utils/request-helper-functions';
export { returnJsonArray } from './utils/return-json-array';
export * from './utils/binary-helper-functions'; export * from './utils/binary-helper-functions';

View file

@ -9,11 +9,10 @@ import type {
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import { getRequestHelperFunctions, getSSHTunnelFunctions } from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context'; import { NodeExecutionContext } from './node-execution-context';
import { extractValue } from './utils/extract-value'; import { extractValue } from './utils/extract-value';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
export class LoadOptionsContext extends NodeExecutionContext implements ILoadOptionsFunctions { export class LoadOptionsContext extends NodeExecutionContext implements ILoadOptionsFunctions {
readonly helpers: ILoadOptionsFunctions['helpers']; readonly helpers: ILoadOptionsFunctions['helpers'];

View file

@ -9,15 +9,11 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getRequestHelperFunctions,
getSchedulingFunctions,
returnJsonArray,
} from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context'; import { NodeExecutionContext } from './node-execution-context';
import { getBinaryHelperFunctions } from './utils/binary-helper-functions'; import { getBinaryHelperFunctions } from './utils/binary-helper-functions';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { returnJsonArray } from './utils/return-json-array';
import { getSchedulingFunctions } from './utils/scheduling-helper-functions';
const throwOnEmit = () => { const throwOnEmit = () => {
throw new ApplicationError('Overwrite PollContext.__emit function'); throw new ApplicationError('Overwrite PollContext.__emit function');

View file

@ -19,16 +19,6 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { createDeferredPromise } from 'n8n-workflow'; import { createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
constructExecutionMetaData,
copyInputItems,
getRequestHelperFunctions,
getSSHTunnelFunctions,
normalizeItems,
returnJsonArray,
} from '@/node-execute-functions';
import { BaseExecuteContext } from './base-execute-context'; import { BaseExecuteContext } from './base-execute-context';
import { import {
assertBinaryData, assertBinaryData,
@ -36,9 +26,16 @@ import {
getBinaryDataBuffer, getBinaryDataBuffer,
getBinaryHelperFunctions, getBinaryHelperFunctions,
} from './utils/binary-helper-functions'; } from './utils/binary-helper-functions';
import { constructExecutionMetaData } from './utils/construct-execution-metadata';
import { copyInputItems } from './utils/copy-input-items';
import { getDeduplicationHelperFunctions } from './utils/deduplication-helper-functions'; import { getDeduplicationHelperFunctions } from './utils/deduplication-helper-functions';
import { getFileSystemHelperFunctions } from './utils/file-system-helper-functions'; import { getFileSystemHelperFunctions } from './utils/file-system-helper-functions';
// eslint-disable-next-line import/no-cycle
import { getInputConnectionData } from './utils/get-input-connection-data'; import { getInputConnectionData } from './utils/get-input-connection-data';
import { normalizeItems } from './utils/normalize-items';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { returnJsonArray } from './utils/return-json-array';
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
export class SupplyDataContext extends BaseExecuteContext implements ISupplyDataFunctions { export class SupplyDataContext extends BaseExecuteContext implements ISupplyDataFunctions {
readonly helpers: ISupplyDataFunctions['helpers']; readonly helpers: ISupplyDataFunctions['helpers'];

View file

@ -9,16 +9,12 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getRequestHelperFunctions,
getSchedulingFunctions,
getSSHTunnelFunctions,
returnJsonArray,
} from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context'; import { NodeExecutionContext } from './node-execution-context';
import { getBinaryHelperFunctions } from './utils/binary-helper-functions'; import { getBinaryHelperFunctions } from './utils/binary-helper-functions';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { returnJsonArray } from './utils/return-json-array';
import { getSchedulingFunctions } from './utils/scheduling-helper-functions';
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
const throwOnEmit = () => { const throwOnEmit = () => {
throw new ApplicationError('Overwrite TriggerContext.emit function'); throw new ApplicationError('Overwrite TriggerContext.emit function');

View file

@ -0,0 +1,44 @@
import type { INodeExecutionData, IPairedItemData, NodeExecutionWithMetadata } from 'n8n-workflow';
import { constructExecutionMetaData } from '../construct-execution-metadata';
describe('constructExecutionMetaData', () => {
const tests: Array<{
description: string;
inputData: INodeExecutionData[];
itemData: IPairedItemData | IPairedItemData[];
expected: NodeExecutionWithMetadata[];
}> = [
{
description: 'should add pairedItem to single input data',
inputData: [{ json: { name: 'John' } }],
itemData: { item: 0 },
expected: [{ json: { name: 'John' }, pairedItem: { item: 0 } }],
},
{
description: 'should add pairedItem to multiple input data with different properties',
inputData: [{ json: { name: 'John' } }, { json: { name: 'Jane' } }],
itemData: [{ item: 0 }, { item: 1 }],
expected: [
{ json: { name: 'John' }, pairedItem: [{ item: 0 }, { item: 1 }] },
{ json: { name: 'Jane' }, pairedItem: [{ item: 0 }, { item: 1 }] },
],
},
{
description: 'should handle empty input data and itemData',
inputData: [],
itemData: [],
expected: [],
},
{
description: 'should handle multiple pairedItem with single input data',
inputData: [{ json: { name: 'John' } }],
itemData: [{ item: 0 }, { item: 1 }],
expected: [{ json: { name: 'John' }, pairedItem: [{ item: 0 }, { item: 1 }] }],
},
];
test.each(tests)('$description', ({ inputData, itemData, expected }) => {
const result = constructExecutionMetaData(inputData, { itemData });
expect(result).toEqual(expected);
});
});

View file

@ -0,0 +1,49 @@
import { copyInputItems } from '../copy-input-items';
describe('copyInputItems', () => {
it('should pick only selected properties', () => {
const output = copyInputItems(
[
{
json: {
a: 1,
b: true,
c: {},
},
},
],
['a'],
);
expect(output).toEqual([{ a: 1 }]);
});
it('should convert undefined to null', () => {
const output = copyInputItems(
[
{
json: {
a: undefined,
},
},
],
['a'],
);
expect(output).toEqual([{ a: null }]);
});
it('should clone objects', () => {
const input = {
a: { b: 5 },
};
const output = copyInputItems(
[
{
json: input,
},
],
['a'],
);
expect(output[0].a).toEqual(input.a);
expect(output[0].a === input.a).toEqual(false);
});
});

View file

@ -0,0 +1,110 @@
import type { IBinaryData, INodeExecutionData } from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import { normalizeItems } from '../normalize-items';
describe('normalizeItems', () => {
describe('should handle', () => {
const successTests: Array<{
description: string;
input: INodeExecutionData | INodeExecutionData[];
expected: INodeExecutionData[];
}> = [
{
description: 'single object without json key',
input: { key: 'value' } as unknown as INodeExecutionData,
expected: [{ json: { key: 'value' } }],
},
{
description: 'array of objects without json key',
input: [{ key1: 'value1' }, { key2: 'value2' }] as unknown as INodeExecutionData[],
expected: [{ json: { key1: 'value1' } }, { json: { key2: 'value2' } }],
},
{
description: 'single object with json key',
input: { json: { key: 'value' } } as INodeExecutionData,
expected: [{ json: { key: 'value' } }],
},
{
description: 'array of objects with json key',
input: [{ json: { key1: 'value1' } }, { json: { key2: 'value2' } }] as INodeExecutionData[],
expected: [{ json: { key1: 'value1' } }, { json: { key2: 'value2' } }],
},
{
description: 'array of objects with binary data',
input: [
{ json: {}, binary: { data: { data: 'binary1', mimeType: 'mime1' } } },
{ json: {}, binary: { data: { data: 'binary2', mimeType: 'mime2' } } },
],
expected: [
{ json: {}, binary: { data: { data: 'binary1', mimeType: 'mime1' } } },
{ json: {}, binary: { data: { data: 'binary2', mimeType: 'mime2' } } },
],
},
{
description: 'object with null or undefined values',
input: { key: null, another: undefined } as unknown as INodeExecutionData,
expected: [{ json: { key: null, another: undefined } }],
},
{
description: 'array with mixed non-standard objects',
input: [{ custom: 'value1' }, { another: 'value2' }] as unknown as INodeExecutionData[],
expected: [{ json: { custom: 'value1' } }, { json: { another: 'value2' } }],
},
{
description: 'empty object',
input: {} as INodeExecutionData,
expected: [{ json: {} }],
},
{
description: 'array with primitive values',
input: [1, 'string', true] as unknown as INodeExecutionData[],
expected: [
{ json: 1 },
{ json: 'string' },
{ json: true },
] as unknown as INodeExecutionData[],
},
];
test.each(successTests)('$description', ({ input, expected }) => {
const result = normalizeItems(input);
expect(result).toEqual(expected);
});
});
describe('should throw error', () => {
const errorTests: Array<{
description: string;
input: INodeExecutionData[];
}> = [
{
description: 'for inconsistent items with some having json key',
input: [{ json: { key1: 'value1' } }, { key2: 'value2' } as unknown as INodeExecutionData],
},
{
description: 'for inconsistent items with some having binary key',
input: [
{ json: {}, binary: { data: { data: 'binary1', mimeType: 'mime1' } } },
{ key: 'value' } as unknown as INodeExecutionData,
],
},
{
description: 'when mixing json and non-json objects with non-json properties',
input: [
{ json: { key1: 'value1' } },
{ other: 'value', custom: 'prop' } as unknown as INodeExecutionData,
],
},
{
description: 'when mixing binary and non-binary objects',
input: [
{ json: {}, binary: { data: { data: 'binarydata' } as IBinaryData } },
{ custom: 'value' } as unknown as INodeExecutionData,
],
},
];
test.each(errorTests)('$description', ({ input }) => {
expect(() => normalizeItems(input)).toThrow(new ApplicationError('Inconsistent item format'));
});
});
});

View file

@ -1,133 +0,0 @@
import FormData from 'form-data';
import type { Agent } from 'https';
import { mock } from 'jest-mock-extended';
import type { IHttpRequestMethods, IRequestOptions } from 'n8n-workflow';
import type { SecureContextOptions } from 'tls';
import { parseRequestObject } from '../parse-request-object';
describe('parseRequestObject', () => {
test('should handle basic request options', async () => {
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: { key: 'value' },
});
expect(axiosOptions).toEqual(
expect.objectContaining({
url: 'https://example.com',
method: 'POST',
headers: { accept: '*/*', 'content-type': 'application/json' },
data: { key: 'value' },
maxRedirects: 0,
}),
);
});
test('should set correct headers for FormData', async () => {
const formData = new FormData();
formData.append('key', 'value');
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
formData,
headers: {
'content-type': 'multipart/form-data',
},
});
expect(axiosOptions.headers).toMatchObject({
accept: '*/*',
'content-length': 163,
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
});
expect(axiosOptions.data).toBeInstanceOf(FormData);
});
test('should not use Host header for SNI', async () => {
const axiosOptions = await parseRequestObject({
url: 'https://example.de/foo/bar',
headers: { Host: 'other.host.com' },
});
expect((axiosOptions.httpsAgent as Agent).options.servername).toEqual('example.de');
});
describe('should set SSL certificates', () => {
const agentOptions: SecureContextOptions = {
ca: '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----',
};
const requestObject: IRequestOptions = {
method: 'GET',
uri: 'https://example.de',
agentOptions,
};
test('on regular requests', async () => {
const axiosOptions = await parseRequestObject(requestObject);
expect((axiosOptions.httpsAgent as Agent).options).toEqual({
servername: 'example.de',
...agentOptions,
noDelay: true,
path: null,
});
});
test('on redirected requests', async () => {
const axiosOptions = await parseRequestObject(requestObject);
expect(axiosOptions.beforeRedirect).toBeDefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const redirectOptions: Record<string, any> = { agents: {}, hostname: 'example.de' };
axiosOptions.beforeRedirect!(redirectOptions, mock());
expect(redirectOptions.agent).toEqual(redirectOptions.agents.https);
expect((redirectOptions.agent as Agent).options).toEqual({
servername: 'example.de',
...agentOptions,
noDelay: true,
path: null,
});
});
});
describe('when followRedirect is true', () => {
test.each(['GET', 'HEAD'] as IHttpRequestMethods[])(
'should set maxRedirects on %s ',
async (method) => {
const axiosOptions = await parseRequestObject({
method,
followRedirect: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(1234);
},
);
test.each(['POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'should not set maxRedirects on %s ',
async (method) => {
const axiosOptions = await parseRequestObject({
method,
followRedirect: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(0);
},
);
});
describe('when followAllRedirects is true', () => {
test.each(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'should set maxRedirects on %s ',
async (method) => {
const axiosOptions = await parseRequestObject({
method,
followAllRedirects: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(1234);
},
);
});
});

View file

@ -0,0 +1,865 @@
import FormData from 'form-data';
import type { Agent } from 'https';
import { mock } from 'jest-mock-extended';
import type {
IHttpRequestMethods,
IHttpRequestOptions,
INode,
IRequestOptions,
IWorkflowExecuteAdditionalData,
PaginationOptions,
Workflow,
} from 'n8n-workflow';
import nock from 'nock';
import type { SecureContextOptions } from 'tls';
import type { ExecutionLifecycleHooks } from '@/execution-engine/execution-lifecycle-hooks';
import {
applyPaginationRequestData,
convertN8nRequestToAxios,
createFormDataObject,
httpRequest,
invokeAxios,
parseRequestObject,
proxyRequestToAxios,
removeEmptyBody,
} from '../request-helper-functions';
describe('Request Helper Functions', () => {
describe('proxyRequestToAxios', () => {
const baseUrl = 'http://example.de';
const workflow = mock<Workflow>();
const hooks = mock<ExecutionLifecycleHooks>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ hooks });
const node = mock<INode>();
beforeEach(() => {
hooks.runHook.mockClear();
});
test('should rethrow an error with `status` property', async () => {
nock(baseUrl).get('/test').reply(400);
try {
await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`);
} catch (error) {
expect(error.status).toEqual(400);
}
});
test('should not throw if the response status is 200', async () => {
nock(baseUrl).get('/test').reply(200);
await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`);
expect(hooks.runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
test('should throw if the response status is 403', async () => {
const headers = { 'content-type': 'text/plain' };
nock(baseUrl).get('/test').reply(403, 'Forbidden', headers);
try {
await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`);
} catch (error) {
expect(error.statusCode).toEqual(403);
expect(error.request).toBeUndefined();
expect(error.response).toMatchObject({ headers, status: 403 });
expect(error.options).toMatchObject({
headers: { Accept: '*/*' },
method: 'get',
url: 'http://example.de/test',
});
expect(error.config).toBeUndefined();
expect(error.message).toEqual('403 - "Forbidden"');
}
expect(hooks.runHook).not.toHaveBeenCalled();
});
test('should not throw if the response status is 404, but `simple` option is set to `false`', async () => {
nock(baseUrl).get('/test').reply(404, 'Not Found');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/test`,
simple: false,
});
expect(response).toEqual('Not Found');
expect(hooks.runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
test('should return full response when `resolveWithFullResponse` is set to true', async () => {
nock(baseUrl).get('/test').reply(404, 'Not Found');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/test`,
resolveWithFullResponse: true,
simple: false,
});
expect(response).toMatchObject({
body: 'Not Found',
headers: {},
statusCode: 404,
statusMessage: 'Not Found',
});
expect(hooks.runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
describe('redirects', () => {
test('should forward authorization header', async () => {
nock(baseUrl).get('/redirect').reply(301, '', { Location: 'https://otherdomain.com/test' });
nock('https://otherdomain.com')
.get('/test')
.reply(200, function () {
return this.req.headers;
});
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
auth: {
username: 'testuser',
password: 'testpassword',
},
headers: {
'X-Other-Header': 'otherHeaderContent',
},
resolveWithFullResponse: true,
});
expect(response.statusCode).toBe(200);
const forwardedHeaders = JSON.parse(response.body);
expect(forwardedHeaders.authorization).toBe('Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk');
expect(forwardedHeaders['x-other-header']).toBe('otherHeaderContent');
});
test('should follow redirects by default', async () => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
resolveWithFullResponse: true,
});
expect(response).toMatchObject({
body: 'Redirected',
headers: {},
statusCode: 200,
});
});
test('should not follow redirects when configured', async () => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
await expect(
proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
resolveWithFullResponse: true,
followRedirect: false,
}),
).rejects.toThrowError(expect.objectContaining({ statusCode: 301 }));
});
});
});
describe('invokeAxios', () => {
const baseUrl = 'http://example.de';
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
it('should throw error for non-401 status codes', async () => {
nock(baseUrl).get('/test').reply(500, {});
await expect(invokeAxios({ url: `${baseUrl}/test` })).rejects.toThrow(
'Request failed with status code 500',
);
});
it('should throw error on 401 without digest auth challenge', async () => {
nock(baseUrl).get('/test').reply(401, {});
await expect(
invokeAxios(
{
url: `${baseUrl}/test`,
},
{ sendImmediately: false },
),
).rejects.toThrow('Request failed with status code 401');
});
it('should make successful requests', async () => {
nock(baseUrl).get('/test').reply(200, { success: true });
const response = await invokeAxios({
url: `${baseUrl}/test`,
});
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
it('should handle digest auth when receiving 401 with nonce', async () => {
nock(baseUrl)
.get('/test')
.matchHeader('authorization', 'Basic dXNlcjpwYXNz')
.once()
.reply(401, {}, { 'www-authenticate': 'Digest realm="test", nonce="abc123", qop="auth"' });
nock(baseUrl)
.get('/test')
.matchHeader(
'authorization',
/^Digest username="user",realm="test",nonce="abc123",uri="\/test",qop="auth",algorithm="MD5",response="[0-9a-f]{32}"/,
)
.reply(200, { success: true });
const response = await invokeAxios(
{
url: `${baseUrl}/test`,
auth: {
username: 'user',
password: 'pass',
},
},
{ sendImmediately: false },
);
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
});
describe('removeEmptyBody', () => {
test.each(['GET', 'HEAD', 'OPTIONS'] as IHttpRequestMethods[])(
'Should remove empty body for %s',
async (method) => {
const requestOptions = {
method,
body: {},
} as IHttpRequestOptions | IRequestOptions;
removeEmptyBody(requestOptions);
expect(requestOptions.body).toEqual(undefined);
},
);
test.each(['GET', 'HEAD', 'OPTIONS'] as IHttpRequestMethods[])(
'Should not remove non-empty body for %s',
async (method) => {
const requestOptions = {
method,
body: { test: true },
} as IHttpRequestOptions | IRequestOptions;
removeEmptyBody(requestOptions);
expect(requestOptions.body).toEqual({ test: true });
},
);
test.each(['POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'Should not remove empty body for %s',
async (method) => {
const requestOptions = {
method,
body: {},
} as IHttpRequestOptions | IRequestOptions;
removeEmptyBody(requestOptions);
expect(requestOptions.body).toEqual({});
},
);
});
describe('parseRequestObject', () => {
test('should handle basic request options', async () => {
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: { key: 'value' },
});
expect(axiosOptions).toEqual(
expect.objectContaining({
url: 'https://example.com',
method: 'POST',
headers: { accept: '*/*', 'content-type': 'application/json' },
data: { key: 'value' },
maxRedirects: 0,
}),
);
});
test('should set correct headers for FormData', async () => {
const formData = new FormData();
formData.append('key', 'value');
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
formData,
headers: {
'content-type': 'multipart/form-data',
},
});
expect(axiosOptions.headers).toMatchObject({
accept: '*/*',
'content-length': 163,
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
});
expect(axiosOptions.data).toBeInstanceOf(FormData);
});
test('should not use Host header for SNI', async () => {
const axiosOptions = await parseRequestObject({
url: 'https://example.de/foo/bar',
headers: { Host: 'other.host.com' },
});
expect((axiosOptions.httpsAgent as Agent).options.servername).toEqual('example.de');
});
describe('should set SSL certificates', () => {
const agentOptions: SecureContextOptions = {
ca: '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----',
};
const requestObject: IRequestOptions = {
method: 'GET',
uri: 'https://example.de',
agentOptions,
};
test('on regular requests', async () => {
const axiosOptions = await parseRequestObject(requestObject);
expect((axiosOptions.httpsAgent as Agent).options).toEqual({
servername: 'example.de',
...agentOptions,
noDelay: true,
path: null,
});
});
test('on redirected requests', async () => {
const axiosOptions = await parseRequestObject(requestObject);
expect(axiosOptions.beforeRedirect).toBeDefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const redirectOptions: Record<string, any> = { agents: {}, hostname: 'example.de' };
axiosOptions.beforeRedirect!(redirectOptions, mock());
expect(redirectOptions.agent).toEqual(redirectOptions.agents.https);
expect((redirectOptions.agent as Agent).options).toEqual({
servername: 'example.de',
...agentOptions,
noDelay: true,
path: null,
});
});
});
describe('when followRedirect is true', () => {
test.each(['GET', 'HEAD'] as IHttpRequestMethods[])(
'should set maxRedirects on %s ',
async (method) => {
const axiosOptions = await parseRequestObject({
method,
followRedirect: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(1234);
},
);
test.each(['POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'should not set maxRedirects on %s ',
async (method) => {
const axiosOptions = await parseRequestObject({
method,
followRedirect: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(0);
},
);
});
describe('when followAllRedirects is true', () => {
test.each(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'should set maxRedirects on %s ',
async (method) => {
const axiosOptions = await parseRequestObject({
method,
followAllRedirects: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(1234);
},
);
});
});
describe('createFormDataObject', () => {
test('should create FormData with simple key-value pairs', () => {
const data = { key1: 'value1', key2: 'value2' };
const formData = createFormDataObject(data);
expect(formData).toBeInstanceOf(FormData);
const formDataEntries: string[] = [];
formData.getHeaders(); // Ensures form data is processed
formData.on('data', (chunk) => {
formDataEntries.push(chunk.toString());
});
});
test('should handle array values', () => {
const data = { files: ['file1.txt', 'file2.txt'] };
const formData = createFormDataObject(data);
expect(formData).toBeInstanceOf(FormData);
});
test('should handle complex form data with options', () => {
const data = {
file: {
value: Buffer.from('test content'),
options: {
filename: 'test.txt',
contentType: 'text/plain',
},
},
};
const formData = createFormDataObject(data);
expect(formData).toBeInstanceOf(FormData);
});
});
describe('convertN8nRequestToAxios', () => {
test('should convert basic HTTP request options', () => {
const requestOptions: IHttpRequestOptions = {
method: 'GET',
url: 'https://example.com',
headers: { 'Custom-Header': 'test' },
qs: { param1: 'value1' },
};
const axiosConfig = convertN8nRequestToAxios(requestOptions);
expect(axiosConfig).toEqual(
expect.objectContaining({
method: 'GET',
url: 'https://example.com',
headers: expect.objectContaining({
'Custom-Header': 'test',
'User-Agent': 'n8n',
}),
params: { param1: 'value1' },
}),
);
});
test('should handle body and content type', () => {
const requestOptions: IHttpRequestOptions = {
method: 'POST',
url: 'https://example.com',
body: { key: 'value' },
headers: { 'content-type': 'application/json' },
};
const axiosConfig = convertN8nRequestToAxios(requestOptions);
expect(axiosConfig).toEqual(
expect.objectContaining({
method: 'POST',
data: { key: 'value' },
headers: expect.objectContaining({
'content-type': 'application/json',
}),
}),
);
});
test('should handle form data', () => {
const formData = new FormData();
formData.append('key', 'value');
const requestOptions: IHttpRequestOptions = {
method: 'POST',
url: 'https://example.com',
body: formData,
};
const axiosConfig = convertN8nRequestToAxios(requestOptions);
expect(axiosConfig).toEqual(
expect.objectContaining({
method: 'POST',
data: formData,
headers: expect.objectContaining({
...formData.getHeaders(),
'User-Agent': 'n8n',
}),
}),
);
});
test('should handle disable follow redirect', () => {
const requestOptions: IHttpRequestOptions = {
method: 'GET',
url: 'https://example.com',
disableFollowRedirect: true,
};
const axiosConfig = convertN8nRequestToAxios(requestOptions);
expect(axiosConfig.maxRedirects).toBe(0);
});
test('should handle SSL certificate validation', () => {
const requestOptions: IHttpRequestOptions = {
method: 'GET',
url: 'https://example.com',
skipSslCertificateValidation: true,
};
const axiosConfig = convertN8nRequestToAxios(requestOptions);
expect(axiosConfig.httpsAgent?.options.rejectUnauthorized).toBe(false);
});
});
describe('applyPaginationRequestData', () => {
test('should merge pagination request data with original request options', () => {
const originalRequestOptions: IRequestOptions = {
uri: 'https://original.com/api',
method: 'GET',
qs: { page: 1 },
headers: { 'X-Original-Header': 'original' },
};
const paginationRequestData: PaginationOptions['request'] = {
url: 'https://pagination.com/api',
body: { key: 'value' },
headers: { 'X-Pagination-Header': 'pagination' },
};
const result = applyPaginationRequestData(originalRequestOptions, paginationRequestData);
expect(result).toEqual({
uri: 'https://pagination.com/api',
url: 'https://pagination.com/api',
method: 'GET',
qs: { page: 1 },
headers: {
'X-Original-Header': 'original',
'X-Pagination-Header': 'pagination',
},
body: { key: 'value' },
});
});
test('should handle formData correctly', () => {
const originalRequestOptions: IRequestOptions = {
uri: 'https://original.com/api',
method: 'POST',
formData: { original: 'data' },
};
const paginationRequestData: PaginationOptions['request'] = {
url: 'https://pagination.com/api',
body: { key: 'value' },
};
const result = applyPaginationRequestData(originalRequestOptions, paginationRequestData);
expect(result).toEqual({
uri: 'https://pagination.com/api',
url: 'https://pagination.com/api',
method: 'POST',
formData: { key: 'value', original: 'data' },
});
});
test('should handle form data correctly', () => {
const originalRequestOptions: IRequestOptions = {
uri: 'https://original.com/api',
method: 'POST',
form: { original: 'data' },
};
const paginationRequestData: PaginationOptions['request'] = {
url: 'https://pagination.com/api',
body: { key: 'value' },
};
const result = applyPaginationRequestData(originalRequestOptions, paginationRequestData);
expect(result).toEqual({
uri: 'https://pagination.com/api',
url: 'https://pagination.com/api',
method: 'POST',
form: { key: 'value', original: 'data' },
});
});
test('should prefer pagination body over original body', () => {
const originalRequestOptions: IRequestOptions = {
uri: 'https://original.com/api',
method: 'POST',
body: { original: 'data' },
};
const paginationRequestData: PaginationOptions['request'] = {
url: 'https://pagination.com/api',
body: { key: 'value' },
};
const result = applyPaginationRequestData(originalRequestOptions, paginationRequestData);
expect(result).toEqual({
uri: 'https://pagination.com/api',
url: 'https://pagination.com/api',
method: 'POST',
body: { key: 'value', original: 'data' },
});
});
test('should merge complex request options', () => {
const originalRequestOptions: IRequestOptions = {
uri: 'https://original.com/api',
method: 'GET',
qs: { page: 1, limit: 10 },
headers: { 'X-Original-Header': 'original' },
body: { filter: 'active' },
};
const paginationRequestData: PaginationOptions['request'] = {
url: 'https://pagination.com/api',
body: { key: 'value' },
headers: { 'X-Pagination-Header': 'pagination' },
qs: { offset: 20 },
};
const result = applyPaginationRequestData(originalRequestOptions, paginationRequestData);
expect(result).toEqual({
uri: 'https://pagination.com/api',
url: 'https://pagination.com/api',
method: 'GET',
qs: { offset: 20, limit: 10, page: 1 },
headers: {
'X-Original-Header': 'original',
'X-Pagination-Header': 'pagination',
},
body: { key: 'value', filter: 'active' },
});
});
test('should handle edge cases with empty pagination data', () => {
const originalRequestOptions: IRequestOptions = {
uri: 'https://original.com/api',
method: 'GET',
};
const paginationRequestData: PaginationOptions['request'] = {};
const result = applyPaginationRequestData(originalRequestOptions, paginationRequestData);
expect(result).toEqual({
uri: 'https://original.com/api',
method: 'GET',
});
});
});
describe('httpRequest', () => {
const baseUrl = 'https://example.com';
beforeEach(() => {
nock.cleanAll();
});
test('should make a simple GET request', async () => {
const scope = nock(baseUrl)
.get('/users')
.reply(200, { users: ['John', 'Jane'] });
const response = await httpRequest({
method: 'GET',
url: `${baseUrl}/users`,
});
expect(response).toEqual({ users: ['John', 'Jane'] });
scope.done();
});
test('should make a POST request with JSON body', async () => {
const requestBody = { name: 'John', age: 30 };
const scope = nock(baseUrl)
.post('/users', requestBody)
.reply(201, { id: '123', ...requestBody });
const response = await httpRequest({
method: 'POST',
url: `${baseUrl}/users`,
body: requestBody,
json: true,
});
expect(response).toEqual({ id: '123', name: 'John', age: 30 });
scope.done();
});
test('should return full response when returnFullResponse is true', async () => {
const scope = nock(baseUrl).get('/data').reply(
200,
{ key: 'value' },
{
'X-Custom-Header': 'test-header',
},
);
const response = await httpRequest({
method: 'GET',
url: `${baseUrl}/data`,
returnFullResponse: true,
});
expect(response).toEqual({
body: { key: 'value' },
headers: expect.objectContaining({
'x-custom-header': 'test-header',
}),
statusCode: 200,
statusMessage: 'OK',
});
scope.done();
});
test('should handle form data request', async () => {
const formData = new FormData();
formData.append('file', 'test content', 'file.txt');
const scope = nock(baseUrl)
.post('/upload')
.matchHeader('content-type', /multipart\/form-data; boundary=/)
.reply(200, { success: true });
const response = await httpRequest({
method: 'POST',
url: `${baseUrl}/upload`,
body: formData,
});
expect(response).toEqual({ success: true });
scope.done();
});
test('should handle query parameters', async () => {
const scope = nock(baseUrl)
.get('/search')
.query({ q: 'test', page: '1' })
.reply(200, { results: ['result1', 'result2'] });
const response = await httpRequest({
method: 'GET',
url: `${baseUrl}/search`,
qs: { q: 'test', page: '1' },
});
expect(response).toEqual({ results: ['result1', 'result2'] });
scope.done();
});
test('should ignore HTTP status errors when configured', async () => {
const scope = nock(baseUrl).get('/error').reply(500, { error: 'Internal Server Error' });
const response = await httpRequest({
method: 'GET',
url: `${baseUrl}/error`,
ignoreHttpStatusErrors: true,
});
expect(response).toEqual({ error: 'Internal Server Error' });
scope.done();
});
test('should handle different array formats in query parameters', async () => {
const scope = nock(baseUrl)
.get('/list')
.query({
tags: ['tag1', 'tag2'],
categories: ['cat1', 'cat2'],
})
.reply(200, { success: true });
const response = await httpRequest({
method: 'GET',
url: `${baseUrl}/list`,
qs: {
tags: ['tag1', 'tag2'],
categories: ['cat1', 'cat2'],
},
arrayFormat: 'indices',
});
expect(response).toEqual({ success: true });
scope.done();
});
test('should remove empty body for GET requests', async () => {
const scope = nock(baseUrl).get('/data').reply(200, { success: true });
const response = await httpRequest({
method: 'GET',
url: `${baseUrl}/data`,
body: {},
});
expect(response).toEqual({ success: true });
scope.done();
});
test('should set default user agent', async () => {
const scope = nock(baseUrl, {
reqheaders: {
'user-agent': 'n8n',
},
})
.get('/test')
.reply(200, { success: true });
const response = await httpRequest({
method: 'GET',
url: `${baseUrl}/test`,
});
expect(response).toEqual({ success: true });
scope.done();
});
test('should respect custom headers', async () => {
const scope = nock(baseUrl, {
reqheaders: {
'X-Custom-Header': 'custom-value',
'user-agent': 'n8n',
},
})
.get('/test')
.reply(200, { success: true });
const response = await httpRequest({
method: 'GET',
url: `${baseUrl}/test`,
headers: { 'X-Custom-Header': 'custom-value' },
});
expect(response).toEqual({ success: true });
scope.done();
});
});
});

View file

@ -0,0 +1,34 @@
import { returnJsonArray } from '../return-json-array';
describe('returnJsonArray', () => {
test.each([
{
input: { name: 'John', age: 30 },
expected: [{ json: { name: 'John', age: 30 } }],
description: 'should convert a single object to an array with json key',
},
{
input: [{ name: 'John' }, { name: 'Jane' }],
expected: [{ json: { name: 'John' } }, { json: { name: 'Jane' } }],
description: 'should return an array of objects with json key',
},
{
input: [{ json: { name: 'John' }, additionalProp: 'value' }],
expected: [{ json: { name: 'John' }, additionalProp: 'value' }],
description: 'should preserve existing json key in object',
},
{
input: [],
expected: [],
description: 'should handle empty array input',
},
{
input: [{ name: 'John' }, { json: { name: 'Jane' }, additionalProp: 'value' }],
expected: [{ json: { name: 'John' } }, { json: { name: 'Jane' }, additionalProp: 'value' }],
description: 'should handle mixed input with some objects having json key',
},
])('$description', ({ input, expected }) => {
const result = returnJsonArray(input);
expect(result).toEqual(expected);
});
});

View file

@ -0,0 +1,31 @@
import { mock } from 'jest-mock-extended';
import type { Workflow } from 'n8n-workflow';
import { mockInstance } from '@test/utils';
import { ScheduledTaskManager } from '../../../scheduled-task-manager';
import { getSchedulingFunctions } from '../scheduling-helper-functions';
describe('getSchedulingFunctions', () => {
const workflow = mock<Workflow>({ id: 'test-workflow' });
const cronExpression = '* * * * * 0';
const onTick = jest.fn();
const scheduledTaskManager = mockInstance(ScheduledTaskManager);
const schedulingFunctions = getSchedulingFunctions(workflow);
it('should return scheduling functions', () => {
expect(typeof schedulingFunctions.registerCron).toBe('function');
});
describe('registerCron', () => {
it('should invoke scheduledTaskManager.registerCron', () => {
schedulingFunctions.registerCron(cronExpression, onTick);
expect(scheduledTaskManager.registerCron).toHaveBeenCalledWith(
workflow,
cronExpression,
onTick,
);
});
});
});

View file

@ -0,0 +1,25 @@
import { mock } from 'jest-mock-extended';
import type { SSHCredentials } from 'n8n-workflow';
import { mockInstance } from '@test/utils';
import { SSHClientsManager } from '../../../ssh-clients-manager';
import { getSSHTunnelFunctions } from '../ssh-tunnel-helper-functions';
describe('getSSHTunnelFunctions', () => {
const credentials = mock<SSHCredentials>();
const sshClientsManager = mockInstance(SSHClientsManager);
const sshTunnelFunctions = getSSHTunnelFunctions();
it('should return SSH tunnel functions', () => {
expect(typeof sshTunnelFunctions.getSSHClient).toBe('function');
});
describe('getSSHClient', () => {
it('should invoke sshClientsManager.getClient', async () => {
await sshTunnelFunctions.getSSHClient(credentials);
expect(sshClientsManager.getClient).toHaveBeenCalledWith(credentials);
});
});
});

View file

@ -0,0 +1,150 @@
import { mock } from 'jest-mock-extended';
import type {
WebhookType,
Workflow,
INode,
IWebhookDescription,
INodeType,
INodeTypes,
Expression,
IWorkflowExecuteAdditionalData,
} from 'n8n-workflow';
import { getWebhookDescription, getNodeWebhookUrl } from '../webhook-helper-functions';
describe('Webhook Helper Functions', () => {
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ id: 'workflow-id', expression, nodeTypes });
const nodeType = mock<INodeType>();
const node = mock<INode>({ name: 'test-node' });
beforeEach(() => jest.resetAllMocks());
describe('getWebhookDescription', () => {
const tests: Array<{
description: string;
name: WebhookType;
webhooks: IWebhookDescription[] | undefined;
expected: IWebhookDescription | undefined;
}> = [
{
description: 'should return undefined for invalid webhook type',
name: 'invalid' as WebhookType,
webhooks: [
{
name: 'default',
httpMethod: 'POST',
path: 'webhook',
},
],
expected: undefined,
},
{
description: 'should return undefined when node has no webhooks',
name: 'default',
webhooks: undefined,
expected: undefined,
},
{
description: 'should return webhook description when webhook exists',
name: 'default',
webhooks: [
{
name: 'default',
httpMethod: 'POST',
path: 'webhook',
},
],
expected: {
name: 'default',
httpMethod: 'POST',
path: 'webhook',
},
},
];
test.each(tests)('$description', ({ name, webhooks, expected }) => {
nodeType.description.webhooks = webhooks;
nodeTypes.getByNameAndVersion.mockReturnValueOnce(nodeType);
const result = getWebhookDescription(name, workflow, node);
expect(result).toEqual(expected);
expect(nodeTypes.getByNameAndVersion).toHaveBeenCalled();
});
});
describe('getNodeWebhookUrl', () => {
const webhookBaseUrl = 'http://localhost:5678/webhook';
const webhookTestBaseUrl = 'http://localhost:5678/webhook-test';
const additionalData = mock<IWorkflowExecuteAdditionalData>({
webhookBaseUrl,
webhookTestBaseUrl,
});
const webhookDescription = mock<IWebhookDescription>({
name: 'default',
isFullPath: false,
});
const tests: Array<{
description: string;
webhookPath: string | undefined;
webhookId: string | undefined;
isTest: boolean;
expected: string;
}> = [
{
description: 'should return webhook URL with path',
webhookPath: 'webhook',
webhookId: undefined,
isTest: false,
expected: `${webhookBaseUrl}/workflow-id/test-node/webhook`,
},
{
description: 'should handle path starting with /',
webhookPath: '/webhook',
webhookId: undefined,
isTest: false,
expected: `${webhookBaseUrl}/workflow-id/test-node/webhook`,
},
{
description: 'should return webhook URL with webhookId',
webhookPath: 'webhook',
webhookId: 'abc123',
isTest: false,
expected: `${webhookBaseUrl}/abc123/webhook`,
},
{
description: 'should return test webhook URL for test webhooks',
webhookPath: 'webhook',
webhookId: undefined,
isTest: true,
expected: `${webhookTestBaseUrl}/workflow-id/test-node/webhook`,
},
];
test.each(tests)('$description', ({ webhookPath, webhookId, isTest, expected }) => {
node.webhookId = webhookId;
if (webhookPath) webhookDescription.path = webhookPath;
nodeType.description.webhooks = [webhookDescription];
nodeTypes.getByNameAndVersion.mockReturnValueOnce(nodeType);
expression.getSimpleParameterValue.mockImplementation((_node, parameterValue) => {
if (parameterValue === 'webhook') return webhookPath;
return parameterValue;
});
const result = getNodeWebhookUrl(
'default',
workflow,
node,
additionalData,
'manual',
{},
isTest,
);
expect(result).toEqual(expected);
expect(expression.getSimpleParameterValue).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,17 @@
import type { INodeExecutionData, IPairedItemData, NodeExecutionWithMetadata } from 'n8n-workflow';
/**
* Takes generic input data and brings it into the new json, pairedItem format n8n uses.
* @param {(IPairedItemData)} itemData
* @param {(INodeExecutionData[])} inputData
*/
export function constructExecutionMetaData(
inputData: INodeExecutionData[],
options: { itemData: IPairedItemData | IPairedItemData[] },
): NodeExecutionWithMetadata[] {
const { itemData } = options;
return inputData.map((data: INodeExecutionData) => {
const { json, ...rest } = data;
return { json, pairedItem: itemData, ...rest } as NodeExecutionWithMetadata;
});
}

View file

@ -0,0 +1,20 @@
import type { INodeExecutionData, IDataObject } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
/**
* Returns a copy of the items which only contains the json data and
* of that only the defined properties
*/
export function copyInputItems(items: INodeExecutionData[], properties: string[]): IDataObject[] {
return items.map((item) => {
const newItem: IDataObject = {};
for (const property of properties) {
if (item.json[property] === undefined) {
newItem[property] = null;
} else {
newItem[property] = deepCopy(item.json[property]);
}
}
return newItem;
});
}

View file

@ -20,9 +20,9 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { createNodeAsTool } from './create-node-as-tool'; import { createNodeAsTool } from './create-node-as-tool';
// eslint-disable-next-line import/no-cycle
import { SupplyDataContext } from '../../node-execution-context';
import type { ExecuteContext, WebhookContext } from '../../node-execution-context'; import type { ExecuteContext, WebhookContext } from '../../node-execution-context';
// eslint-disable-next-line import/no-cycle
import { SupplyDataContext } from '../../node-execution-context/supply-data-context';
export async function getInputConnectionData( export async function getInputConnectionData(
this: ExecuteContext | WebhookContext | SupplyDataContext, this: ExecuteContext | WebhookContext | SupplyDataContext,

View file

@ -0,0 +1,47 @@
import type { INodeExecutionData, IDataObject } from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
/**
* Automatically put the objects under a 'json' key and don't error,
* if some objects contain json/binary keys and others don't, throws error 'Inconsistent item format'
*
* @param {INodeExecutionData | INodeExecutionData[]} executionData
*/
export function normalizeItems(
executionData: INodeExecutionData | INodeExecutionData[],
): INodeExecutionData[] {
if (typeof executionData === 'object' && !Array.isArray(executionData)) {
executionData = executionData.json ? [executionData] : [{ json: executionData as IDataObject }];
}
if (executionData.every((item) => typeof item === 'object' && 'json' in item))
return executionData;
if (executionData.some((item) => typeof item === 'object' && 'json' in item)) {
throw new ApplicationError('Inconsistent item format');
}
if (executionData.every((item) => typeof item === 'object' && 'binary' in item)) {
const normalizedItems: INodeExecutionData[] = [];
executionData.forEach((item) => {
const json = Object.keys(item).reduce((acc, key) => {
if (key === 'binary') return acc;
return { ...acc, [key]: item[key] };
}, {});
normalizedItems.push({
json,
binary: item.binary,
});
});
return normalizedItems;
}
if (executionData.some((item) => typeof item === 'object' && 'binary' in item)) {
throw new ApplicationError('Inconsistent item format');
}
return executionData.map((item) => {
return { json: item };
});
}

View file

@ -1,468 +0,0 @@
import { Container } from '@n8n/di';
import type { AxiosHeaders, AxiosRequestConfig } from 'axios';
import crypto from 'crypto';
import FormData from 'form-data';
import { Agent, type AgentOptions } from 'https';
import type { GenericValue, IRequestOptions } from 'n8n-workflow';
import { stringify } from 'qs';
import { URL } from 'url';
import { Logger } from '@/logging/logger';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pushFormDataValue = (form: FormData, key: string, value: any) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
if (value?.hasOwnProperty('value') && value.hasOwnProperty('options')) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
form.append(key, value.value, value.options);
} else {
form.append(key, value);
}
};
const createFormDataObject = (data: Record<string, unknown>) => {
const formData = new FormData();
const keys = Object.keys(data);
keys.forEach((key) => {
const formField = data[key];
if (formField instanceof Array) {
formField.forEach((item) => {
pushFormDataValue(formData, key, item);
});
} else {
pushFormDataValue(formData, key, formField);
}
});
return formData;
};
function searchForHeader(config: AxiosRequestConfig, headerName: string) {
if (config.headers === undefined) {
return undefined;
}
const headerNames = Object.keys(config.headers);
headerName = headerName.toLowerCase();
return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName);
}
async function generateContentLengthHeader(config: AxiosRequestConfig) {
if (!(config.data instanceof FormData)) {
return;
}
try {
const length = await new Promise<number>((res, rej) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
config.data.getLength((error: Error | null, dataLength: number) => {
if (error) rej(error);
else res(dataLength);
});
});
config.headers = {
...config.headers,
'content-length': length,
};
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
Container.get(Logger).error('Unable to calculate form data length', { error });
}
}
const getHostFromRequestObject = (
requestObject: Partial<{
url: string;
uri: string;
baseURL: string;
}>,
): string | null => {
try {
const url = (requestObject.url ?? requestObject.uri) as string;
return new URL(url, requestObject.baseURL).hostname;
} catch (error) {
return null;
}
};
const getBeforeRedirectFn =
(agentOptions: AgentOptions, axiosConfig: AxiosRequestConfig) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(redirectedRequest: Record<string, any>) => {
const redirectAgent = new Agent({
...agentOptions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
servername: redirectedRequest.hostname,
});
redirectedRequest.agent = redirectAgent;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
redirectedRequest.agents.https = redirectAgent;
if (axiosConfig.headers?.Authorization) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
redirectedRequest.headers.Authorization = axiosConfig.headers.Authorization;
}
if (axiosConfig.auth) {
redirectedRequest.auth = `${axiosConfig.auth.username}:${axiosConfig.auth.password}`;
}
};
/**
* This function is a temporary implementation that translates all http requests
* done via the request library to axios directly.
* We are not using n8n's interface as it would an unnecessary step,
* considering the `request` helper has been be deprecated and should be removed.
* @deprecated This is only used by legacy request helpers, that are also deprecated
*/
// eslint-disable-next-line complexity
export async function parseRequestObject(requestObject: IRequestOptions) {
const axiosConfig: AxiosRequestConfig = {};
if (requestObject.headers !== undefined) {
axiosConfig.headers = requestObject.headers as AxiosHeaders;
}
// Let's start parsing the hardest part, which is the request body.
// The process here is as following?
// - Check if we have a `content-type` header. If this was set,
// we will follow
// - Check if the `form` property was set. If yes, then it's x-www-form-urlencoded
// - Check if the `formData` property exists. If yes, then it's multipart/form-data
// - Lastly, we should have a regular `body` that is probably a JSON.
const contentTypeHeaderKeyName =
axiosConfig.headers &&
Object.keys(axiosConfig.headers).find(
(headerName) => headerName.toLowerCase() === 'content-type',
);
const contentType =
contentTypeHeaderKeyName &&
(axiosConfig.headers?.[contentTypeHeaderKeyName] as string | undefined);
if (contentType === 'application/x-www-form-urlencoded' && requestObject.formData === undefined) {
// there are nodes incorrectly created, informing the content type header
// and also using formData. Request lib takes precedence for the formData.
// We will do the same.
// Merge body and form properties.
if (typeof requestObject.body === 'string') {
axiosConfig.data = requestObject.body;
} else {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const allData = Object.assign(requestObject.body || {}, requestObject.form || {}) as Record<
string,
string
>;
if (requestObject.useQuerystring === true) {
axiosConfig.data = stringify(allData, { arrayFormat: 'repeat' });
} else {
axiosConfig.data = stringify(allData);
}
}
} else if (contentType?.includes('multipart/form-data')) {
if (requestObject.formData !== undefined && requestObject.formData instanceof FormData) {
axiosConfig.data = requestObject.formData;
} else {
const allData: Partial<FormData> = {
...(requestObject.body as object | undefined),
...(requestObject.formData as object | undefined),
};
axiosConfig.data = createFormDataObject(allData);
}
// replace the existing header with a new one that
// contains the boundary property.
delete axiosConfig.headers?.[contentTypeHeaderKeyName!];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const headers = axiosConfig.data.getHeaders();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/prefer-nullish-coalescing
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig);
} else {
// When using the `form` property it means the content should be x-www-form-urlencoded.
if (requestObject.form !== undefined && requestObject.body === undefined) {
// If we have only form
axiosConfig.data =
typeof requestObject.form === 'string'
? stringify(requestObject.form, { format: 'RFC3986' })
: stringify(requestObject.form).toString();
if (axiosConfig.headers !== undefined) {
const headerName = searchForHeader(axiosConfig, 'content-type');
if (headerName) {
delete axiosConfig.headers[headerName];
}
axiosConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded';
} else {
axiosConfig.headers = {
'Content-Type': 'application/x-www-form-urlencoded',
};
}
} else if (requestObject.formData !== undefined) {
// remove any "content-type" that might exist.
if (axiosConfig.headers !== undefined) {
const headers = Object.keys(axiosConfig.headers);
headers.forEach((header) => {
if (header.toLowerCase() === 'content-type') {
delete axiosConfig.headers?.[header];
}
});
}
if (requestObject.formData instanceof FormData) {
axiosConfig.data = requestObject.formData;
} else {
axiosConfig.data = createFormDataObject(requestObject.formData as Record<string, unknown>);
}
// Mix in headers as FormData creates the boundary.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const headers = axiosConfig.data.getHeaders();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/prefer-nullish-coalescing
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig);
} else if (requestObject.body !== undefined) {
// If we have body and possibly form
if (requestObject.form !== undefined && requestObject.body) {
// merge both objects when exist.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
requestObject.body = Object.assign(requestObject.body, requestObject.form);
}
axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[];
}
}
if (requestObject.uri !== undefined) {
axiosConfig.url = requestObject.uri?.toString();
}
if (requestObject.url !== undefined) {
axiosConfig.url = requestObject.url?.toString();
}
if (requestObject.baseURL !== undefined) {
axiosConfig.baseURL = requestObject.baseURL?.toString();
}
if (requestObject.method !== undefined) {
axiosConfig.method = requestObject.method;
}
if (requestObject.qs !== undefined && Object.keys(requestObject.qs as object).length > 0) {
axiosConfig.params = requestObject.qs;
}
function hasArrayFormatOptions(
arg: IRequestOptions,
): arg is Required<Pick<IRequestOptions, 'qsStringifyOptions'>> {
if (
typeof arg.qsStringifyOptions === 'object' &&
arg.qsStringifyOptions !== null &&
!Array.isArray(arg.qsStringifyOptions) &&
'arrayFormat' in arg.qsStringifyOptions
) {
return true;
}
return false;
}
if (
requestObject.useQuerystring === true ||
(hasArrayFormatOptions(requestObject) &&
requestObject.qsStringifyOptions.arrayFormat === 'repeat')
) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'repeat' });
};
} else if (requestObject.useQuerystring === false) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'indices' });
};
}
if (
hasArrayFormatOptions(requestObject) &&
requestObject.qsStringifyOptions.arrayFormat === 'brackets'
) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'brackets' });
};
}
if (requestObject.auth !== undefined) {
// Check support for sendImmediately
if (requestObject.auth.bearer !== undefined) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
Authorization: `Bearer ${requestObject.auth.bearer}`,
});
} else {
const authObj = requestObject.auth;
// Request accepts both user/username and pass/password
axiosConfig.auth = {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
username: (authObj.user || authObj.username) as string,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
password: (authObj.password || authObj.pass) as string,
};
}
}
// Only set header if we have a body, otherwise it may fail
if (requestObject.json === true) {
// Add application/json headers - do not set charset as it breaks a lot of stuff
// only add if no other accept headers was sent.
const acceptHeaderExists =
axiosConfig.headers === undefined
? false
: Object.keys(axiosConfig.headers)
.map((headerKey) => headerKey.toLowerCase())
.includes('accept');
if (!acceptHeaderExists) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
Accept: 'application/json',
});
}
}
if (requestObject.json === false || requestObject.json === undefined) {
// Prevent json parsing
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
axiosConfig.transformResponse = (res) => res;
}
// Axios will follow redirects by default, so we simply tell it otherwise if needed.
const { method } = requestObject;
if (
(requestObject.followRedirect !== false &&
(!method || method === 'GET' || method === 'HEAD')) ||
requestObject.followAllRedirects
) {
axiosConfig.maxRedirects = requestObject.maxRedirects;
} else {
axiosConfig.maxRedirects = 0;
}
const host = getHostFromRequestObject(requestObject);
const agentOptions: AgentOptions = { ...requestObject.agentOptions };
if (host) {
agentOptions.servername = host;
}
if (requestObject.rejectUnauthorized === false) {
agentOptions.rejectUnauthorized = false;
agentOptions.secureOptions = crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT;
}
axiosConfig.httpsAgent = new Agent(agentOptions);
axiosConfig.beforeRedirect = getBeforeRedirectFn(agentOptions, axiosConfig);
if (requestObject.timeout !== undefined) {
axiosConfig.timeout = requestObject.timeout;
}
if (requestObject.proxy !== undefined) {
// try our best to parse the url provided.
if (typeof requestObject.proxy === 'string') {
try {
const url = new URL(requestObject.proxy);
// eslint-disable-next-line @typescript-eslint/no-shadow
const host = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;
axiosConfig.proxy = {
host,
port: parseInt(url.port, 10),
protocol: url.protocol,
};
if (!url.port) {
// Sets port to a default if not informed
if (url.protocol === 'http') {
axiosConfig.proxy.port = 80;
} else if (url.protocol === 'https') {
axiosConfig.proxy.port = 443;
}
}
if (url.username || url.password) {
axiosConfig.proxy.auth = {
username: url.username,
password: url.password,
};
}
} catch (error) {
// Not a valid URL. We will try to simply parse stuff
// such as user:pass@host:port without protocol (we'll assume http)
if (requestObject.proxy.includes('@')) {
const [userpass, hostport] = requestObject.proxy.split('@');
const [username, password] = userpass.split(':');
const [hostname, port] = hostport.split(':');
// eslint-disable-next-line @typescript-eslint/no-shadow
const host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
axiosConfig.proxy = {
host,
port: parseInt(port, 10),
protocol: 'http',
auth: {
username,
password,
},
};
} else if (requestObject.proxy.includes(':')) {
const [hostname, port] = requestObject.proxy.split(':');
axiosConfig.proxy = {
host: hostname,
port: parseInt(port, 10),
protocol: 'http',
};
} else {
axiosConfig.proxy = {
host: requestObject.proxy,
port: 80,
protocol: 'http',
};
}
}
} else {
axiosConfig.proxy = requestObject.proxy;
}
}
if (requestObject.useStream) {
axiosConfig.responseType = 'stream';
} else if (requestObject.encoding === null) {
// When downloading files, return an arrayBuffer.
axiosConfig.responseType = 'arraybuffer';
}
// If we don't set an accept header
// Axios forces "application/json, text/plan, */*"
// Which causes some nodes like NextCloud to break
// as the service returns XML unless requested otherwise.
const allHeaders = axiosConfig.headers ? Object.keys(axiosConfig.headers) : [];
if (!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'accept')) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { accept: '*/*' });
}
if (
requestObject.json !== false &&
axiosConfig.data !== undefined &&
axiosConfig.data !== '' &&
!(axiosConfig.data instanceof Buffer) &&
!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
) {
// Use default header for application/json
// If we don't specify this here, axios will add
// application/json; charset=utf-8
// and this breaks a lot of stuff
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
'content-type': 'application/json',
});
}
if (requestObject.simple === false) {
axiosConfig.validateStatus = () => true;
}
/**
* Missing properties:
* encoding (need testing)
* gzip (ignored - default already works)
* resolveWithFullResponse (implemented elsewhere)
*/
return axiosConfig;
}

View file

@ -0,0 +1,25 @@
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
/**
* Takes generic input data and brings it into the json format n8n uses.
*
* @param {(IDataObject | IDataObject[])} jsonData
*/
export function returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[] {
const returnData: INodeExecutionData[] = [];
if (!Array.isArray(jsonData)) {
jsonData = [jsonData];
}
jsonData.forEach((data: IDataObject & { json?: IDataObject }) => {
if (data?.json) {
// We already have the JSON key so avoid double wrapping
returnData.push({ ...data, json: data.json });
} else {
returnData.push({ json: data });
}
});
return returnData;
}

View file

@ -0,0 +1,12 @@
import { Container } from '@n8n/di';
import type { SchedulingFunctions, Workflow } from 'n8n-workflow';
import { ScheduledTaskManager } from '../../scheduled-task-manager';
export const getSchedulingFunctions = (workflow: Workflow): SchedulingFunctions => {
const scheduledTaskManager = Container.get(ScheduledTaskManager);
return {
registerCron: (cronExpression, onTick) =>
scheduledTaskManager.registerCron(workflow, cronExpression, onTick),
};
};

View file

@ -0,0 +1,11 @@
import { Container } from '@n8n/di';
import type { SSHTunnelFunctions } from 'n8n-workflow';
import { SSHClientsManager } from '../../ssh-clients-manager';
export const getSSHTunnelFunctions = (): SSHTunnelFunctions => {
const sshClientsManager = Container.get(SSHClientsManager);
return {
getSSHClient: async (credentials) => await sshClientsManager.getClient(credentials),
};
};

View file

@ -0,0 +1,67 @@
import type {
WebhookType,
Workflow,
INode,
IWorkflowExecuteAdditionalData,
WorkflowExecuteMode,
IWorkflowDataProxyAdditionalKeys,
IWebhookDescription,
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
/** Returns the full webhook description of the webhook with the given name */
export function getWebhookDescription(
name: WebhookType,
workflow: Workflow,
node: INode,
): IWebhookDescription | undefined {
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
// Node does not have any webhooks so return
if (nodeType.description.webhooks === undefined) return;
for (const webhookDescription of nodeType.description.webhooks) {
if (webhookDescription.name === name) {
return webhookDescription;
}
}
return undefined;
}
/** Returns the webhook URL of the webhook with the given name */
export function getNodeWebhookUrl(
name: WebhookType,
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
additionalKeys: IWorkflowDataProxyAdditionalKeys,
isTest?: boolean,
): string | undefined {
let baseUrl = additionalData.webhookBaseUrl;
if (isTest === true) {
baseUrl = additionalData.webhookTestBaseUrl;
}
const webhookDescription = getWebhookDescription(name, workflow, node);
if (webhookDescription === undefined) return;
const path = workflow.expression.getSimpleParameterValue(
node,
webhookDescription.path,
mode,
additionalKeys,
);
if (path === undefined) return;
const isFullPath: boolean = workflow.expression.getSimpleParameterValue(
node,
webhookDescription.isFullPath,
mode,
additionalKeys,
undefined,
false,
) as boolean;
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id, node, path.toString(), isFullPath);
}

View file

@ -18,16 +18,12 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getNodeWebhookUrl,
getRequestHelperFunctions,
returnJsonArray,
} from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context'; import { NodeExecutionContext } from './node-execution-context';
import { copyBinaryFile, getBinaryHelperFunctions } from './utils/binary-helper-functions'; import { copyBinaryFile, getBinaryHelperFunctions } from './utils/binary-helper-functions';
import { getInputConnectionData } from './utils/get-input-connection-data'; import { getInputConnectionData } from './utils/get-input-connection-data';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { returnJsonArray } from './utils/return-json-array';
import { getNodeWebhookUrl } from './utils/webhook-helper-functions';
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions { export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
readonly helpers: IWebhookFunctions['helpers']; readonly helpers: IWebhookFunctions['helpers'];

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import { get } from 'lodash'; import { get } from 'lodash';
import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; import { constructExecutionMetaData } from 'n8n-core';
import type { IDataObject, IExecuteFunctions, IGetNodeParameterOptions, INode } from 'n8n-workflow'; import type { IDataObject, IExecuteFunctions, IGetNodeParameterOptions, INode } from 'n8n-workflow';
import { Readable } from 'stream'; import { Readable } from 'stream';
@ -32,7 +32,7 @@ export const createMockExecuteFunction = (
}, },
helpers: { helpers: {
constructExecutionMetaData, constructExecutionMetaData,
returnJsonArray, returnJsonArray: () => [],
prepareBinaryData: () => {}, prepareBinaryData: () => {},
httpRequest: () => {}, httpRequest: () => {},
}, },