first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,309 @@
import FormData from 'form-data';
import get from 'lodash/get';
import isPlainObject from 'lodash/isPlainObject';
import set from 'lodash/set';
import {
deepCopy,
setSafeObjectProperty,
type ICredentialDataDecryptedObject,
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IOAuth2Options,
type IRequestOptions,
} from 'n8n-workflow';
import type { SecureContextOptions } from 'tls';
import type { HttpSslAuthCredentials } from './interfaces';
import { formatPrivateKey } from '../../utils/utilities';
export type BodyParameter = {
name: string;
value: string;
parameterType?: 'formBinaryData' | 'formData';
};
export type IAuthDataSanitizeKeys = {
[key: string]: string[];
};
export const replaceNullValues = (item: INodeExecutionData) => {
if (item.json === null) {
item.json = {};
}
return item;
};
export const REDACTED = '**hidden**';
function isObject(obj: unknown): obj is IDataObject {
return isPlainObject(obj);
}
function redact<T = unknown>(obj: T, secrets: string[]): T {
if (typeof obj === 'string') {
return secrets.reduce((safe, secret) => safe.replace(secret, REDACTED), obj) as T;
}
if (Array.isArray(obj)) {
return obj.map((item) => redact(item, secrets)) as T;
} else if (isObject(obj)) {
for (const [key, value] of Object.entries(obj)) {
setSafeObjectProperty(obj, key, redact(value, secrets));
}
}
return obj;
}
export function sanitizeUiMessage(
request: IRequestOptions,
authDataKeys: IAuthDataSanitizeKeys,
secrets?: string[],
) {
const { body, ...rest } = request as IDataObject;
let sendRequest: IDataObject = { body };
for (const [key, value] of Object.entries(rest)) {
sendRequest[key] = deepCopy(value);
}
// Protect browser from sending large binary data
if (Buffer.isBuffer(sendRequest.body) && sendRequest.body.length > 250000) {
sendRequest = {
...request,
body: `Binary data got replaced with this text. Original was a Buffer with a size of ${
(request.body as string).length
} bytes.`,
};
}
// Remove credential information
for (const requestProperty of Object.keys(authDataKeys)) {
sendRequest = {
...sendRequest,
[requestProperty]: Object.keys(sendRequest[requestProperty] as object).reduce(
(acc: IDataObject, curr) => {
acc[curr] = authDataKeys[requestProperty].includes(curr)
? REDACTED
: (sendRequest[requestProperty] as IDataObject)[curr];
return acc;
},
{},
),
};
}
const HEADER_BLOCKLIST = new Set([
'authorization',
'x-api-key',
'x-auth-token',
'cookie',
'proxy-authorization',
'sslclientcert',
]);
const headers = sendRequest.headers as IDataObject;
if (headers) {
for (const headerName of Object.keys(headers)) {
if (HEADER_BLOCKLIST.has(headerName.toLowerCase())) {
headers[headerName] = REDACTED;
}
}
}
if (secrets && secrets.length > 0) {
return redact(sendRequest, secrets);
}
return sendRequest;
}
export function getSecrets(
properties: INodeProperties[],
credentials: ICredentialDataDecryptedObject,
): string[] {
const sensitivePropNames = new Set(
properties.filter((prop) => prop.typeOptions?.password).map((prop) => prop.name),
);
const secrets = Object.entries(credentials)
.filter(([propName]) => sensitivePropNames.has(propName))
.map(([_, value]) => value)
.filter((value): value is string => typeof value === 'string');
const oauthAccessToken = get(credentials, 'oauthTokenData.access_token');
if (typeof oauthAccessToken === 'string') {
secrets.push(oauthAccessToken);
}
return secrets;
}
export const getOAuth2AdditionalParameters = (nodeCredentialType: string) => {
const oAuth2Options: { [credentialType: string]: IOAuth2Options } = {
bitlyOAuth2Api: {
tokenType: 'Bearer',
},
boxOAuth2Api: {
includeCredentialsOnRefreshOnBody: true,
},
ciscoWebexOAuth2Api: {
tokenType: 'Bearer',
},
clickUpOAuth2Api: {
keepBearer: false,
tokenType: 'Bearer',
},
goToWebinarOAuth2Api: {
tokenExpiredStatusCode: 403,
},
hubspotDeveloperApi: {
tokenType: 'Bearer',
includeCredentialsOnRefreshOnBody: true,
},
hubspotOAuth2Api: {
tokenType: 'Bearer',
includeCredentialsOnRefreshOnBody: true,
},
lineNotifyOAuth2Api: {
tokenType: 'Bearer',
},
linkedInOAuth2Api: {
tokenType: 'Bearer',
},
mailchimpOAuth2Api: {
tokenType: 'Bearer',
},
mauticOAuth2Api: {
includeCredentialsOnRefreshOnBody: true,
},
microsoftAzureMonitorOAuth2Api: {
tokenExpiredStatusCode: 403,
},
microsoftDynamicsOAuth2Api: {
property: 'id_token',
},
philipsHueOAuth2Api: {
tokenType: 'Bearer',
},
raindropOAuth2Api: {
includeCredentialsOnRefreshOnBody: true,
},
shopifyOAuth2Api: {
tokenType: 'Bearer',
keyToIncludeInAccessTokenHeader: 'X-Shopify-Access-Token',
},
slackOAuth2Api: {
tokenType: 'Bearer',
property: 'authed_user.access_token',
},
stravaOAuth2Api: {
includeCredentialsOnRefreshOnBody: true,
},
};
return oAuth2Options[nodeCredentialType];
};
//https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
export const binaryContentTypes = [
'image/',
'audio/',
'video/',
'application/octet-stream',
'application/gzip',
'application/zip',
'application/vnd.rar',
'application/epub+zip',
'application/x-bzip',
'application/x-bzip2',
'application/x-cdf',
'application/vnd.amazon.ebook',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-fontobject',
'application/vnd.oasis.opendocument.presentation',
'application/pdf',
'application/x-tar',
'application/vnd.visio',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/x-7z-compressed',
];
export type BodyParametersReducer = (
acc: IDataObject,
cur: { name: string; value: string },
) => Promise<IDataObject>;
export async function reduceAsync<T, R>(
arr: T[],
reducer: (acc: Awaited<Promise<R>>, cur: T) => Promise<R>,
init: Promise<R> = Promise.resolve({} as R),
): Promise<R> {
return await arr.reduce(async (promiseAcc, item) => {
return await reducer(await promiseAcc, item);
}, init);
}
export const prepareRequestBody = async (
parameters: BodyParameter[],
bodyType: string,
version: number,
defaultReducer: BodyParametersReducer,
) => {
if (bodyType === 'json' && version >= 4) {
return await parameters.reduce(async (acc, entry) => {
const result = await acc;
set(result, entry.name, entry.value);
return result;
}, Promise.resolve({}));
} else if (bodyType === 'multipart-form-data' && version >= 4.2) {
const formData = new FormData();
for (const parameter of parameters) {
if (parameter.parameterType === 'formBinaryData') {
const entry = await defaultReducer({}, parameter);
const key = Object.keys(entry)[0];
const data = entry[key] as { value: Buffer; options: FormData.AppendOptions };
formData.append(key, data.value, data.options);
continue;
}
formData.append(parameter.name, parameter.value);
}
return formData;
} else {
return await reduceAsync(parameters, defaultReducer);
}
};
export const setAgentOptions = (
requestOptions: IRequestOptions,
sslCertificates: HttpSslAuthCredentials | undefined,
) => {
if (sslCertificates) {
const agentOptions: SecureContextOptions = {};
if (sslCertificates.ca) agentOptions.ca = formatPrivateKey(sslCertificates.ca);
if (sslCertificates.cert) agentOptions.cert = formatPrivateKey(sslCertificates.cert);
if (sslCertificates.key) agentOptions.key = formatPrivateKey(sslCertificates.key);
if (sslCertificates.passphrase)
agentOptions.passphrase = formatPrivateKey(sslCertificates.passphrase);
requestOptions.agentOptions = agentOptions;
}
};
export const updadeQueryParameterConfig = (version: number) => {
if (version < 4.3) {
return (qs: IDataObject, name: string, value: string) => (qs[name] = value);
} else {
return (qs: { [key: string]: any }, name: string, value: any) => {
if (qs[name] === undefined) {
qs[name] = value;
} else if (Array.isArray(qs[name])) {
qs[name].push(value);
} else {
qs[name] = [qs[name], value];
}
};
}
};
@@ -0,0 +1,133 @@
{
"node": "n8n-nodes-base.httpRequest",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/"
}
],
"generic": [
{
"label": "2021: The Year to Automate the New You with n8n",
"icon": "☀️",
"url": "https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/"
},
{
"label": "Why business process automation with n8n can change your daily life",
"icon": "🧬",
"url": "https://n8n.io/blog/why-business-process-automation-with-n8n-can-change-your-daily-life/"
},
{
"label": "Automatically pulling and visualizing data with n8n",
"icon": "📈",
"url": "https://n8n.io/blog/automatically-pulling-and-visualizing-data-with-n8n/"
},
{
"label": "Learn how to automatically cross-post your content with n8n",
"icon": "✍️",
"url": "https://n8n.io/blog/learn-how-to-automatically-cross-post-your-content-with-n8n/"
},
{
"label": "Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n",
"icon": "🧾",
"url": "https://n8n.io/blog/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n/"
},
{
"label": "Running n8n on ships: An interview with Maranics",
"icon": "🛳",
"url": "https://n8n.io/blog/running-n8n-on-ships-an-interview-with-maranics/"
},
{
"label": "What are APIs and how to use them with no code",
"icon": " 🪢",
"url": "https://n8n.io/blog/what-are-apis-how-to-use-them-with-no-code/"
},
{
"label": "5 tasks you can automate with the new Notion API ",
"icon": "⚡️",
"url": "https://n8n.io/blog/5-tasks-you-can-automate-with-notion-api/"
},
{
"label": "Celebrating World Poetry Day with a daily poem in Telegram",
"icon": "📜",
"url": "https://n8n.io/blog/world-poetry-day-workflow/"
},
{
"label": "15 Google apps you can combine and automate to increase productivity",
"icon": "💡",
"url": "https://n8n.io/blog/automate-google-apps-for-productivity/"
},
{
"label": "Automate Designs with Bannerbear and n8n",
"icon": "🎨",
"url": "https://n8n.io/blog/automate-designs-with-bannerbear-and-n8n/"
},
{
"label": "How uProc scraped a multi-page website with a low-code workflow",
"icon": " 🕸️",
"url": "https://n8n.io/blog/how-uproc-scraped-a-multi-page-website-with-a-low-code-workflow/"
},
{
"label": "Building an expense tracking app in 10 minutes",
"icon": "📱",
"url": "https://n8n.io/blog/building-an-expense-tracking-app-in-10-minutes/"
},
{
"label": "5 workflow automations for Mattermost that we love at n8n",
"icon": "🤖",
"url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/"
},
{
"label": "How to use the HTTP Request Node - The Swiss Army Knife for Workflow Automation",
"icon": "🧰",
"url": "https://n8n.io/blog/how-to-use-the-http-request-node-the-swiss-army-knife-for-workflow-automation/"
},
{
"label": "Learn how to use webhooks with Mattermost slash commands",
"icon": "🦄",
"url": "https://n8n.io/blog/learn-how-to-use-webhooks-with-mattermost-slash-commands/"
},
{
"label": "How a Membership Development Manager automates his work and investments",
"icon": "📈",
"url": "https://n8n.io/blog/how-a-membership-development-manager-automates-his-work-and-investments/"
},
{
"label": "A low-code bitcoin ticker built with QuestDB and n8n.io",
"icon": "📈",
"url": "https://n8n.io/blog/a-low-code-bitcoin-ticker-built-with-questdb-and-n8n-io/"
},
{
"label": "How to set up a no-code CI/CD pipeline with GitHub and TravisCI",
"icon": "🎡",
"url": "https://n8n.io/blog/how-to-set-up-a-ci-cd-pipeline-with-no-code/"
},
{
"label": "How Common Knowledge use workflow automation for activism",
"icon": "✨",
"url": "https://n8n.io/blog/automations-for-activists/"
},
{
"label": "Creating scheduled text affirmations with n8n",
"icon": "🤟",
"url": "https://n8n.io/blog/creating-scheduled-text-affirmations-with-n8n/"
},
{
"label": "How Goomer automated their operations with over 200 n8n workflows",
"icon": "🛵",
"url": "https://n8n.io/blog/how-goomer-automated-their-operations-with-over-200-n8n-workflows/"
},
{
"label": "7 no-code workflow automations for Amazon Web Services",
"url": "https://n8n.io/blog/aws-workflow-automation/"
}
]
},
"alias": ["API", "Request", "URL", "Build", "cURL"],
"subcategories": {
"Core Nodes": ["Helpers"]
}
}
@@ -0,0 +1,37 @@
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { HttpRequestV1 } from './V1/HttpRequestV1.node';
import { HttpRequestV2 } from './V2/HttpRequestV2.node';
import { HttpRequestV3 } from './V3/HttpRequestV3.node';
export class HttpRequest extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'HTTP Request',
name: 'httpRequest',
icon: { light: 'file:httprequest.svg', dark: 'file:httprequest.dark.svg' },
group: ['output'],
subtitle: '={{$parameter["requestMethod"] + ": " + $parameter["url"]}}',
description: 'Makes an HTTP request and returns the response data',
defaultVersion: 4.4,
builderHint: {
message:
'Prefer dedicated integration nodes over HTTP Request — n8n has 400+ dedicated nodes (e.g. Gmail, Slack, Google Sheets, Notion, OpenAI, HubSpot, Jira, etc.) with built-in authentication, pre-configured parameters, better error handling, and easier maintenance. Only use HTTP Request when no dedicated node exists for the service, the user explicitly requests it, accessing a custom/internal API, or the dedicated node does not support the specific operation needed.',
},
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new HttpRequestV1(baseDescription),
2: new HttpRequestV2(baseDescription),
3: new HttpRequestV3(baseDescription),
4: new HttpRequestV3(baseDescription),
4.1: new HttpRequestV3(baseDescription),
4.2: new HttpRequestV3(baseDescription),
4.3: new HttpRequestV3(baseDescription),
4.4: new HttpRequestV3(baseDescription),
};
super(nodeVersions, baseDescription);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
import type { IBinaryData, IRequestOptions } from 'n8n-workflow';
export const setFilename = (
preparedBinaryData: IBinaryData,
requestOptions: IRequestOptions,
responseFileName: string | undefined,
) => {
if (
!preparedBinaryData.fileName &&
preparedBinaryData.fileExtension &&
typeof requestOptions.uri === 'string' &&
requestOptions.uri.endsWith(preparedBinaryData.fileExtension)
) {
return requestOptions.uri.split('/').pop();
}
if (!preparedBinaryData.fileName && preparedBinaryData.fileExtension) {
return `${responseFileName ?? 'data'}.${preparedBinaryData.fileExtension}`;
}
return preparedBinaryData.fileName;
};
@@ -0,0 +1,72 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import type { Readable } from 'stream';
const CHINESE_ENCODINGS = ['gb18030', 'gbk', 'gb2312'] as const;
const REPLACEMENT_CHAR = '';
const HIGH_ASCII_PATTERN = /[\x80-\xFF]{3,}/;
const DEFAULT_ENCODING = 'utf-8';
/**
* Enhanced encoding detection for better handling of non-UTF-8 content
* Extracts charset from Content-Type header (e.g., "text/html; charset=utf-8" → "utf-8")
*/
function detectEncoding(contentType?: string): BufferEncoding | undefined {
if (!contentType) return undefined;
// Regex breakdown:
// /charset=([^;,\s]+)/i
// - charset= : Match literal "charset=" (case-insensitive due to 'i' flag)
// - ([^;,\s]+) : Capture group that matches one or more characters that are NOT:
// ^ = negation, ; = semicolon, , = comma, \s = any whitespace
// - i : Case-insensitive flag (matches "charset=", "CHARSET=", "Charset=", etc.)
const charsetMatch = contentType.match(/charset=([^;,\s]+)/i);
if (charsetMatch) {
// charsetMatch[1] contains the captured group (the charset value)
// Convert to lowercase and remove any surrounding quotes
return charsetMatch[1].toLowerCase().replace(/['"]/g, '') as BufferEncoding;
}
return undefined;
}
/**
* Enhanced binary to string conversion for better handling of non-UTF-8 content
*/
export async function binaryToStringWithEncodingDetection(
body: Buffer | Readable,
contentType: string,
helpers: IExecuteFunctions['helpers'],
): Promise<string> {
let bufferedData: Buffer;
if (body instanceof Buffer) {
bufferedData = body;
} else {
bufferedData = await helpers.binaryToBuffer(body);
}
const encoding = detectEncoding(contentType);
if (encoding && encoding !== DEFAULT_ENCODING) {
return await helpers.binaryToString(bufferedData, encoding);
}
const decodedString = await helpers.binaryToString(bufferedData);
if (decodedString.includes(REPLACEMENT_CHAR) || HIGH_ASCII_PATTERN.test(decodedString)) {
const detected = helpers.detectBinaryEncoding(bufferedData).toLowerCase() as BufferEncoding;
if (detected && detected !== DEFAULT_ENCODING) {
return await helpers.binaryToString(bufferedData, detected);
}
for (const chinese of CHINESE_ENCODINGS) {
try {
const reDecoded = await helpers.binaryToString(bufferedData, chinese as BufferEncoding);
if (!reDecoded.includes(REPLACEMENT_CHAR) && reDecoded.length > 0) return reDecoded;
} catch {}
}
}
return decodedString;
}
@@ -0,0 +1,9 @@
export const mimeTypeFromResponse = (
responseContentType: string | undefined,
): string | undefined => {
if (!responseContentType) {
return undefined;
}
return responseContentType.split(' ')[0].split(';')[0];
};
@@ -0,0 +1,439 @@
import { Readable } from 'stream';
import type { IExecuteFunctions } from 'n8n-workflow';
import { binaryToStringWithEncodingDetection } from '../buffer-decoding';
describe('buffer-decoding utils', () => {
let mockHelpers: IExecuteFunctions['helpers'];
let mockBinaryToString: jest.MockedFunction<
(body: Buffer | Readable, encoding?: BufferEncoding) => Promise<string>
>;
let mockBinaryToBuffer: jest.MockedFunction<(body: Buffer | Readable) => Promise<Buffer>>;
let mockDetectBinaryEncoding: jest.MockedFunction<(buffer: Buffer) => string>;
beforeEach(() => {
jest.clearAllMocks();
mockBinaryToString = jest.fn();
mockBinaryToBuffer = jest.fn();
mockDetectBinaryEncoding = jest.fn();
mockHelpers = {
binaryToString: mockBinaryToString,
binaryToBuffer: mockBinaryToBuffer,
detectBinaryEncoding: mockDetectBinaryEncoding,
} as unknown as IExecuteFunctions['helpers'];
});
describe('binaryToStringWithEncodingDetection', () => {
describe('Content-Type header encoding detection', () => {
it('should use encoding from Content-Type header (lowercase)', async () => {
const buffer = Buffer.from('test content', 'utf8');
const contentType = 'text/html; charset=iso-8859-1';
mockBinaryToString.mockResolvedValue('test content');
await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(mockBinaryToString).toHaveBeenCalledWith(buffer, 'iso-8859-1');
});
it('should use encoding from Content-Type header (uppercase)', async () => {
const buffer = Buffer.from('test content', 'utf8');
const contentType = 'text/html; CHARSET=UTF-16';
mockBinaryToString.mockResolvedValue('test content');
await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(mockBinaryToString).toHaveBeenCalledWith(buffer, 'utf-16');
});
it('should remove quotes from charset value', async () => {
const buffer = Buffer.from('test content', 'utf8');
const contentType = 'text/html; charset="utf-8"';
mockBinaryToString.mockResolvedValue('test content');
await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
// Since utf-8 is the default encoding, it should call without encoding parameter
expect(mockBinaryToString).toHaveBeenCalledWith(buffer);
});
it('should handle charset with single quotes', async () => {
const buffer = Buffer.from('test content', 'utf8');
const contentType = "text/html; charset='iso-8859-1'";
mockBinaryToString.mockResolvedValue('test content');
await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(mockBinaryToString).toHaveBeenCalledWith(buffer, 'iso-8859-1');
});
it('should handle charset in complex Content-Type header', async () => {
const buffer = Buffer.from('test content', 'utf8');
const contentType = 'text/html; boundary=something; charset=windows-1252; other=value';
mockBinaryToString.mockResolvedValue('test content');
await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(mockBinaryToString).toHaveBeenCalledWith(buffer, 'windows-1252');
});
it('should fall back to UTF-8 when no charset in Content-Type', async () => {
const buffer = Buffer.from('test content', 'utf8');
const contentType = 'text/html';
mockBinaryToString.mockResolvedValueOnce('test content');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(mockBinaryToString).toHaveBeenCalledWith(buffer);
expect(result).toBe('test content');
});
});
describe('UTF-8 fallback behavior', () => {
it('should return UTF-8 decoded string when no encoding issues detected', async () => {
const buffer = Buffer.from('test content', 'utf8');
const contentType = 'text/html';
mockBinaryToString.mockResolvedValue('test content');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(result).toBe('test content');
expect(mockBinaryToString).toHaveBeenCalledTimes(1);
});
it('should not trigger re-encoding when UTF-8 content is clean', async () => {
const buffer = Buffer.from('Hello World! 🌍', 'utf8');
const contentType = 'text/html';
mockBinaryToString.mockResolvedValue('Hello World! 🌍');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(result).toBe('Hello World! 🌍');
expect(mockBinaryToString).toHaveBeenCalledTimes(1);
});
});
describe('Replacement character detection and re-encoding', () => {
it('should detect replacement characters and try chardet for Buffer', async () => {
const buffer = Buffer.from('test content with ', 'utf8');
const contentType = 'text/html';
mockBinaryToString
.mockResolvedValueOnce('test content with ') // First UTF-8 attempt
.mockResolvedValueOnce('test content with proper chars'); // Second attempt with detected encoding
mockDetectBinaryEncoding.mockReturnValue('iso-8859-1');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(mockDetectBinaryEncoding).toHaveBeenCalledWith(buffer);
expect(mockBinaryToString).toHaveBeenCalledTimes(2);
expect(mockBinaryToString).toHaveBeenNthCalledWith(1, buffer);
expect(mockBinaryToString).toHaveBeenNthCalledWith(2, buffer, 'iso-8859-1');
expect(result).toBe('test content with proper chars');
});
it('should detect high ASCII pattern and try chardet for Buffer', async () => {
const buffer = Buffer.from([0x80, 0x81, 0x82, 0x83]); // High ASCII bytes
const contentType = 'text/html';
const highAsciiString = String.fromCharCode(0x80, 0x81, 0x82, 0x83);
mockBinaryToString
.mockResolvedValueOnce(highAsciiString) // First UTF-8 attempt
.mockResolvedValueOnce('proper decoded content'); // Second attempt with detected encoding
mockDetectBinaryEncoding.mockReturnValue('windows-1252');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(mockDetectBinaryEncoding).toHaveBeenCalledWith(buffer);
expect(mockBinaryToString).toHaveBeenCalledTimes(2);
expect(mockBinaryToString).toHaveBeenNthCalledWith(2, buffer, 'windows-1252');
expect(result).toBe('proper decoded content');
});
it('should try Chinese encodings for Readable streams with replacement chars', async () => {
const readable = new Readable();
readable.push('content with ');
readable.push(null);
const contentType = 'text/html';
const buffer = Buffer.from('content with ');
mockBinaryToBuffer.mockResolvedValue(buffer);
mockBinaryToString
.mockResolvedValueOnce('content with ') // First UTF-8 attempt
.mockResolvedValueOnce('content with proper chars'); // gb18030 attempt (success)
// Mock chardet to return empty string so it tries Chinese encodings
mockDetectBinaryEncoding.mockReturnValue('');
const result = await binaryToStringWithEncodingDetection(
readable,
contentType,
mockHelpers,
);
expect(mockBinaryToString).toHaveBeenCalledTimes(2);
expect(mockBinaryToString).toHaveBeenNthCalledWith(1, buffer);
expect(mockBinaryToString).toHaveBeenNthCalledWith(2, buffer, 'gb18030');
expect(result).toBe('content with proper chars');
});
it('should try all Chinese encodings for Readable streams if needed', async () => {
const readable = new Readable();
readable.push('content with ');
readable.push(null);
const contentType = 'text/html';
const buffer = Buffer.from('content with ');
mockBinaryToBuffer.mockResolvedValue(buffer);
mockBinaryToString
.mockResolvedValueOnce('content with ') // First UTF-8 attempt
.mockResolvedValueOnce('content with ') // gb18030 attempt
.mockResolvedValueOnce('content with proper chars'); // gbk attempt (success)
// Mock chardet to return empty string so it tries Chinese encodings
mockDetectBinaryEncoding.mockReturnValue('');
const result = await binaryToStringWithEncodingDetection(
readable,
contentType,
mockHelpers,
);
expect(mockBinaryToString).toHaveBeenCalledTimes(3);
expect(mockBinaryToString).toHaveBeenNthCalledWith(3, buffer, 'gbk');
expect(result).toBe('content with proper chars');
});
it('should return original string if all Chinese encodings fail for Readable', async () => {
const readable = new Readable();
readable.push('content with ');
readable.push(null);
const contentType = 'text/html';
const buffer = Buffer.from('content with ');
mockBinaryToBuffer.mockResolvedValue(buffer);
mockBinaryToString
.mockResolvedValueOnce('content with ') // First UTF-8 attempt
.mockResolvedValueOnce('content with ') // gb18030 attempt
.mockResolvedValueOnce('content with ') // gbk attempt
.mockResolvedValueOnce('content with '); // gb2312 attempt
mockDetectBinaryEncoding.mockReturnValue('');
const result = await binaryToStringWithEncodingDetection(
readable,
contentType,
mockHelpers,
);
expect(result).toBe('content with ');
});
});
describe('Error handling', () => {
it('should handle chardet returning null for Buffer', async () => {
const buffer = Buffer.from('test content with ', 'utf8');
const contentType = 'text/html';
mockBinaryToBuffer.mockResolvedValue(buffer);
mockBinaryToString.mockResolvedValue('test content with ');
mockDetectBinaryEncoding.mockReturnValue('');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(result).toBe('test content with ');
expect(mockBinaryToString).toHaveBeenCalledTimes(4); // 1 initial + 3 Chinese encodings
});
it('should handle chardet returning same encoding as default', async () => {
const buffer = Buffer.from('test content with ', 'utf8');
const contentType = 'text/html';
mockBinaryToBuffer.mockResolvedValue(buffer);
mockBinaryToString.mockResolvedValue('test content with ');
mockDetectBinaryEncoding.mockReturnValue('utf-8');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(result).toBe('test content with ');
expect(mockBinaryToString).toHaveBeenCalledTimes(4); // 1 initial + 3 Chinese encodings
});
it('should handle errors in Chinese encoding attempts gracefully', async () => {
const readable = new Readable();
readable.push('content with ');
readable.push(null);
const contentType = 'text/html';
const buffer = Buffer.from('content with ');
mockBinaryToBuffer.mockResolvedValue(buffer);
mockBinaryToString
.mockResolvedValueOnce('content with ') // First UTF-8 attempt
.mockRejectedValueOnce(new Error('Encoding error')) // gb18030 error
.mockResolvedValueOnce('content with proper chars'); // gbk success
mockDetectBinaryEncoding.mockReturnValue('');
const result = await binaryToStringWithEncodingDetection(
readable,
contentType,
mockHelpers,
);
expect(result).toBe('content with proper chars');
});
it('should return original string if all Chinese encodings throw errors', async () => {
const readable = new Readable();
readable.push('content with ');
readable.push(null);
const contentType = 'text/html';
const buffer = Buffer.from('content with ');
mockBinaryToBuffer.mockResolvedValue(buffer);
mockBinaryToString
.mockResolvedValueOnce('content with ') // First UTF-8 attempt
.mockRejectedValue(new Error('Encoding error')); // All Chinese encodings fail
mockDetectBinaryEncoding.mockReturnValue('');
const result = await binaryToStringWithEncodingDetection(
readable,
contentType,
mockHelpers,
);
expect(result).toBe('content with ');
});
});
describe('Edge cases', () => {
it('should handle empty content', async () => {
const buffer = Buffer.from('', 'utf8');
const contentType = 'text/html';
mockBinaryToString.mockResolvedValue('');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(result).toBe('');
expect(mockBinaryToString).toHaveBeenCalledTimes(1);
});
it('should handle empty Readable stream', async () => {
const readable = new Readable();
readable.push(null); // Empty stream
const contentType = 'text/html';
const buffer = Buffer.from('');
mockBinaryToBuffer.mockResolvedValue(buffer);
mockBinaryToString.mockResolvedValue('');
const result = await binaryToStringWithEncodingDetection(
readable,
contentType,
mockHelpers,
);
expect(result).toBe('');
});
it('should not re-encode if Chinese encoding returns empty string', async () => {
const readable = new Readable();
readable.push('content with ');
readable.push(null);
const contentType = 'text/html';
const buffer = Buffer.from('content with ');
mockBinaryToBuffer.mockResolvedValue(buffer);
mockBinaryToString
.mockResolvedValueOnce('content with ') // First UTF-8 attempt
.mockResolvedValueOnce(''); // gb18030 returns empty
mockDetectBinaryEncoding.mockReturnValue('');
const result = await binaryToStringWithEncodingDetection(
readable,
contentType,
mockHelpers,
);
expect(mockBinaryToString).toHaveBeenCalledTimes(4); // Should try all encodings
expect(result).toBe('content with '); // Should return original
});
it('should handle very short high ASCII sequences (less than 3 chars)', async () => {
const buffer = Buffer.from([0x80, 0x81]); // Only 2 high ASCII bytes
const contentType = 'text/html';
const shortHighAsciiString = String.fromCharCode(0x80, 0x81);
mockBinaryToString.mockResolvedValue(shortHighAsciiString);
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(result).toBe(shortHighAsciiString);
expect(mockBinaryToString).toHaveBeenCalledTimes(1); // Should not trigger re-encoding
expect(mockDetectBinaryEncoding).not.toHaveBeenCalled();
});
it('should handle mixed content with both replacement chars and high ASCII', async () => {
const buffer = Buffer.from(
'test content ' + String.fromCharCode(0x80, 0x81, 0x82),
'utf8',
);
const contentType = 'text/html';
mockBinaryToString
.mockResolvedValueOnce('test content ' + String.fromCharCode(0x80, 0x81, 0x82))
.mockResolvedValueOnce('test proper content decoded');
mockDetectBinaryEncoding.mockReturnValue('windows-1252');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(result).toBe('test proper content decoded');
expect(mockDetectBinaryEncoding).toHaveBeenCalledWith(buffer);
});
});
describe('Non-UTF-8 encoding specified in Content-Type', () => {
it('should skip re-encoding when non-UTF-8 encoding is specified and used', async () => {
const buffer = Buffer.from('test content', 'utf8');
const contentType = 'text/html; charset=iso-8859-1';
mockBinaryToString.mockResolvedValue('test content');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
expect(result).toBe('test content');
expect(mockBinaryToString).toHaveBeenCalledTimes(1);
expect(mockBinaryToString).toHaveBeenCalledWith(buffer, 'iso-8859-1');
expect(mockDetectBinaryEncoding).not.toHaveBeenCalled();
});
it('should return result from specified encoding even if it contains replacement chars', async () => {
const buffer = Buffer.from('test content with ', 'utf8');
const contentType = 'text/html; charset=iso-8859-1';
mockBinaryToString.mockResolvedValue('test content with ');
const result = await binaryToStringWithEncodingDetection(buffer, contentType, mockHelpers);
// When a specific encoding is provided, the function uses it directly without re-encoding
expect(result).toBe('test content with ');
expect(mockBinaryToString).toHaveBeenCalledTimes(1);
expect(mockBinaryToString).toHaveBeenCalledWith(buffer, 'iso-8859-1');
expect(mockDetectBinaryEncoding).not.toHaveBeenCalled();
});
});
});
});
@@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 20C40 8.95314 31.0469 0 20 0C8.95314 0 0 8.95314 0 20C0 31.0469 8.95314 40 20 40C31.0469 40 40 31.0469 40 20ZM20 36.9458C18.8852 36.9458 17.1378 35.967 15.4998 32.6985C14.7964 31.2918 14.1961 29.5431 13.7526 27.6847H26.1898C25.8045 29.5403 25.2044 31.2901 24.5002 32.6985C22.8622 35.967 21.1148 36.9458 20 36.9458ZM12.9064 20C12.9064 21.6097 13.0087 23.164 13.2003 24.6305H26.7997C26.9913 23.164 27.0936 21.6097 27.0936 20C27.0936 18.3903 26.9913 16.836 26.7997 15.3695H13.2003C13.0087 16.836 12.9064 18.3903 12.9064 20ZM20 3.05419C21.1149 3.05419 22.8622 4.03078 24.5001 7.30039C25.2066 8.71408 25.8072 10.4067 26.192 12.3153H13.7501C14.1933 10.4047 14.7942 8.71254 15.4998 7.30064C17.1377 4.03083 18.8851 3.05419 20 3.05419ZM30.1478 20C30.1478 18.4099 30.0543 16.8617 29.8227 15.3695H36.3042C36.7252 16.842 36.9458 18.3964 36.9458 20C36.9458 21.6036 36.7252 23.158 36.3042 24.6305H29.8227C30.0543 23.1383 30.1478 21.5901 30.1478 20ZM26.2767 4.25512C27.6365 6.36019 28.711 9.132 29.3774 12.3153H35.1046C33.2511 8.668 30.107 5.78346 26.2767 4.25512ZM10.6226 12.3153H4.89293C6.75147 8.66784 9.89351 5.78341 13.7232 4.25513C12.3635 6.36021 11.289 9.13201 10.6226 12.3153ZM3.05419 20C3.05419 21.603 3.27743 23.1575 3.69484 24.6305H10.1217C9.94619 23.142 9.85222 21.5943 9.85222 20C9.85222 18.4057 9.94619 16.858 10.1217 15.3695H3.69484C3.27743 16.8425 3.05419 18.397 3.05419 20ZM26.2766 35.7427C27.6365 33.6393 28.711 30.868 29.3774 27.6847H35.1046C33.251 31.3322 30.1068 34.2179 26.2766 35.7427ZM13.7234 35.7427C9.89369 34.2179 6.75155 31.3324 4.89293 27.6847H10.6226C11.289 30.868 12.3635 33.6393 13.7234 35.7427Z" fill="#8F87F7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 20C40 8.95314 31.0469 0 20 0C8.95314 0 0 8.95314 0 20C0 31.0469 8.95314 40 20 40C31.0469 40 40 31.0469 40 20ZM20 36.9458C18.8852 36.9458 17.1378 35.967 15.4998 32.6985C14.7964 31.2918 14.1961 29.5431 13.7526 27.6847H26.1898C25.8045 29.5403 25.2044 31.2901 24.5002 32.6985C22.8622 35.967 21.1148 36.9458 20 36.9458ZM12.9064 20C12.9064 21.6097 13.0087 23.164 13.2003 24.6305H26.7997C26.9913 23.164 27.0936 21.6097 27.0936 20C27.0936 18.3903 26.9913 16.836 26.7997 15.3695H13.2003C13.0087 16.836 12.9064 18.3903 12.9064 20ZM20 3.05419C21.1149 3.05419 22.8622 4.03078 24.5001 7.30039C25.2066 8.71408 25.8072 10.4067 26.192 12.3153H13.7501C14.1933 10.4047 14.7942 8.71254 15.4998 7.30064C17.1377 4.03083 18.8851 3.05419 20 3.05419ZM30.1478 20C30.1478 18.4099 30.0543 16.8617 29.8227 15.3695H36.3042C36.7252 16.842 36.9458 18.3964 36.9458 20C36.9458 21.6036 36.7252 23.158 36.3042 24.6305H29.8227C30.0543 23.1383 30.1478 21.5901 30.1478 20ZM26.2767 4.25512C27.6365 6.36019 28.711 9.132 29.3774 12.3153H35.1046C33.2511 8.668 30.107 5.78346 26.2767 4.25512ZM10.6226 12.3153H4.89293C6.75147 8.66784 9.89351 5.78341 13.7232 4.25513C12.3635 6.36021 11.289 9.13201 10.6226 12.3153ZM3.05419 20C3.05419 21.603 3.27743 23.1575 3.69484 24.6305H10.1217C9.94619 23.142 9.85222 21.5943 9.85222 20C9.85222 18.4057 9.94619 16.858 10.1217 15.3695H3.69484C3.27743 16.8425 3.05419 18.397 3.05419 20ZM26.2766 35.7427C27.6365 33.6393 28.711 30.868 29.3774 27.6847H35.1046C33.251 31.3322 30.1068 34.2179 26.2766 35.7427ZM13.7234 35.7427C9.89369 34.2179 6.75155 31.3324 4.89293 27.6847H10.6226C11.289 30.868 12.3635 33.6393 13.7234 35.7427Z" fill="#3A42E9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,6 @@
export type HttpSslAuthCredentials = {
ca?: string;
cert?: string;
key?: string;
passphrase?: string;
};
@@ -0,0 +1,124 @@
import { type IExecuteFunctions, NodeOperationError } from 'n8n-workflow';
import { configureResponseOptimizer } from './optimizeResponse';
describe('configureResponseOptimizer', () => {
const mockCtx = {
getNodeParameter: jest.fn(),
getNode: jest.fn(),
} as unknown as jest.Mocked<IExecuteFunctions>;
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the original response when optimizeResponse is false', () => {
mockCtx.getNodeParameter.mockImplementation((param) => {
if (param === 'optimizeResponse') return false;
});
const optimizer = configureResponseOptimizer(mockCtx, 0);
const response = { key: 'value' };
expect(optimizer(response)).toBe(response);
});
describe('htmlOptimizer', () => {
it('should optimize HTML response based on CSS selector', () => {
mockCtx.getNodeParameter.mockImplementation((param) => {
if (param === 'optimizeResponse') return true;
if (param === 'responseType') return 'html';
if (param === 'cssSelector') return 'div';
if (param === 'onlyContent') return false;
});
const optimizer = configureResponseOptimizer(mockCtx, 0);
const response = '<div>Hello</div><div>World</div>';
expect(optimizer(response)).toEqual('[\n "Hello",\n "World"\n]');
});
});
describe('textOptimizer', () => {
it('should extract readable text from HTML response', () => {
mockCtx.getNodeParameter.mockImplementation((param) => {
if (param === 'optimizeResponse') return true;
if (param === 'responseType') return 'text';
});
const optimizer = configureResponseOptimizer(mockCtx, 0);
const response = '<html><body><h1>Title</h1><p>Content</p></body></html>';
expect(optimizer(response)).toContain('Title');
expect(optimizer(response)).toContain('Content');
});
it('should truncate text if maxLength is set', () => {
mockCtx.getNodeParameter.mockImplementation((param) => {
if (param === 'optimizeResponse') return true;
if (param === 'responseType') return 'text';
if (param === 'truncateResponse') return true;
if (param === 'maxLength') return 5;
});
const optimizer = configureResponseOptimizer(mockCtx, 0);
const response = '<html><body><p>Content</p></body></html>';
expect(optimizer(response)).toEqual('Conte');
});
});
describe('jsonOptimizer', () => {
it('should parse JSON response and include all fields by default', () => {
mockCtx.getNodeParameter.mockImplementation((param) => {
if (param === 'optimizeResponse') return true;
if (param === 'responseType') return 'json';
if (param === 'fieldsToInclude') return 'all';
});
const optimizer = configureResponseOptimizer(mockCtx, 0);
const response = '{"key": "value"}';
expect(optimizer(response)).toEqual([{ key: 'value' }]);
});
it('should include only selected fields', () => {
mockCtx.getNodeParameter.mockImplementation((param) => {
if (param === 'optimizeResponse') return true;
if (param === 'responseType') return 'json';
if (param === 'fieldsToInclude') return 'selected';
if (param === 'fields') return 'key';
});
const optimizer = configureResponseOptimizer(mockCtx, 0);
const response = [{ key: 'value', otherKey: 'otherValue' }];
expect(optimizer(response)).toEqual([{ key: 'value' }]);
});
it('should exclude specified fields', () => {
mockCtx.getNodeParameter.mockImplementation((param) => {
if (param === 'optimizeResponse') return true;
if (param === 'responseType') return 'json';
if (param === 'fieldsToInclude') return 'except';
if (param === 'fields') return 'otherKey';
});
const optimizer = configureResponseOptimizer(mockCtx, 0);
const response = [{ key: 'value', otherKey: 'otherValue' }];
expect(optimizer(response)).toEqual([{ key: 'value' }]);
});
it('should throw an error if response is not a valid JSON object', () => {
mockCtx.getNodeParameter.mockImplementation((param) => {
if (param === 'optimizeResponse') return true;
if (param === 'responseType') return 'json';
});
const optimizer = configureResponseOptimizer(mockCtx, 0);
expect(() => optimizer('invalid json')).toThrow(NodeOperationError);
});
});
});
@@ -0,0 +1,432 @@
import { Readability } from '@mozilla/readability';
import * as cheerio from 'cheerio';
import { convert } from 'html-to-text';
import { JSDOM } from 'jsdom';
import get from 'lodash/get';
import set from 'lodash/set';
import unset from 'lodash/unset';
import {
type INodeProperties,
jsonParse,
NodeOperationError,
type IDataObject,
type IExecuteFunctions,
} from 'n8n-workflow';
type ResponseOptimizerFn = (
x: IDataObject | IDataObject[] | string,
) => IDataObject | IDataObject[] | string;
function htmlOptimizer(
ctx: IExecuteFunctions,
itemIndex: number,
maxLength: number,
): ResponseOptimizerFn {
const cssSelector = ctx.getNodeParameter('cssSelector', itemIndex, '') as string;
const onlyContent = ctx.getNodeParameter('onlyContent', itemIndex, false) as boolean;
let elementsToOmit: string[] = [];
if (onlyContent) {
const elementsToOmitUi = ctx.getNodeParameter('elementsToOmit', itemIndex, '') as
| string
| string[];
if (typeof elementsToOmitUi === 'string') {
elementsToOmit = elementsToOmitUi
.split(',')
.filter((s) => s)
.map((s) => s.trim());
}
}
return (response) => {
if (typeof response !== 'string') {
throw new NodeOperationError(
ctx.getNode(),
`The response type must be a string. Received: ${typeof response}`,
{ itemIndex },
);
}
const returnData: string[] = [];
const html = cheerio.load(response);
const htmlElements = html(cssSelector);
htmlElements.each((_, el) => {
let value = html(el).html() || '';
if (onlyContent) {
let htmlToTextOptions;
if (elementsToOmit?.length) {
htmlToTextOptions = {
selectors: elementsToOmit.map((selector) => ({
selector,
format: 'skip',
})),
};
}
value = convert(value, htmlToTextOptions);
}
value = value
.trim()
.replace(/^\s+|\s+$/g, '')
.replace(/(\r\n|\n|\r)/gm, '')
.replace(/\s+/g, ' ');
returnData.push(value);
});
const text = JSON.stringify(returnData, null, 2);
if (maxLength > 0 && text.length > maxLength) {
return text.substring(0, maxLength);
}
return text;
};
}
const textOptimizer = (
ctx: IExecuteFunctions,
itemIndex: number,
maxLength: number,
): ResponseOptimizerFn => {
return (response) => {
if (typeof response === 'object') {
try {
response = JSON.stringify(response, null, 2);
} catch (error) {}
}
if (typeof response !== 'string') {
throw new NodeOperationError(
ctx.getNode(),
`The response type must be a string. Received: ${typeof response}`,
{ itemIndex },
);
}
const dom = new JSDOM(response);
const article = new Readability(dom.window.document, {
keepClasses: true,
}).parse();
const text = article?.textContent || '';
if (maxLength > 0 && text.length > maxLength) {
return text.substring(0, maxLength);
}
return text;
};
};
const jsonOptimizer = (ctx: IExecuteFunctions, itemIndex: number): ResponseOptimizerFn => {
return (response) => {
let responseData: IDataObject | IDataObject[] | string | null = response;
if (typeof response === 'string') {
try {
responseData = jsonParse(response, { errorMessage: 'Invalid JSON response' });
} catch (error) {
throw new NodeOperationError(
ctx.getNode(),
`Received invalid JSON from response '${response}'`,
{ itemIndex },
);
}
}
if (typeof responseData !== 'object' || !responseData) {
throw new NodeOperationError(
ctx.getNode(),
'The response type must be an object or an array of objects',
{ itemIndex },
);
}
const dataField = ctx.getNodeParameter('dataField', itemIndex, '') as string;
let returnData: IDataObject[] = [];
if (!Array.isArray(responseData)) {
if (dataField) {
if (!Object.prototype.hasOwnProperty.call(responseData, dataField)) {
throw new NodeOperationError(
ctx.getNode(),
`Target field "${dataField}" not found in response.`,
{
itemIndex,
description: `The response contained these fields: [${Object.keys(responseData).join(', ')}]`,
},
);
}
const data = responseData[dataField] as IDataObject | IDataObject[];
if (Array.isArray(data)) {
responseData = data;
} else {
responseData = [data];
}
} else {
responseData = [responseData];
}
} else {
if (dataField) {
responseData = responseData.map((data) => data[dataField]) as IDataObject[];
}
}
const fieldsToInclude = ctx.getNodeParameter('fieldsToInclude', itemIndex, 'all') as
| 'all'
| 'selected'
| 'except';
let fields: string | string[] = [];
if (fieldsToInclude !== 'all') {
fields = ctx.getNodeParameter('fields', itemIndex, []) as string[] | string;
if (typeof fields === 'string') {
fields = fields.split(',').map((field) => field.trim());
}
} else {
returnData = responseData;
}
if (fieldsToInclude === 'selected') {
for (const item of responseData) {
const newItem: IDataObject = {};
for (const field of fields) {
set(newItem, field, get(item, field));
}
returnData.push(newItem);
}
}
if (fieldsToInclude === 'except') {
for (const item of responseData) {
for (const field of fields) {
unset(item, field);
}
returnData.push(item);
}
}
return returnData;
};
};
export const configureResponseOptimizer = (
ctx: IExecuteFunctions,
itemIndex: number,
): ResponseOptimizerFn => {
const optimizeResponse = ctx.getNodeParameter('optimizeResponse', itemIndex, false) as boolean;
if (optimizeResponse) {
const responseType = ctx.getNodeParameter('responseType', itemIndex) as
| 'json'
| 'text'
| 'html';
let maxLength = 0;
const truncateResponse = ctx.getNodeParameter('truncateResponse', itemIndex, false) as boolean;
if (truncateResponse) {
maxLength = ctx.getNodeParameter('maxLength', itemIndex, 0) as number;
}
switch (responseType) {
case 'html':
return htmlOptimizer(ctx, itemIndex, maxLength);
case 'text':
return textOptimizer(ctx, itemIndex, maxLength);
case 'json':
return jsonOptimizer(ctx, itemIndex);
}
}
return (x) => x;
};
export const optimizeResponseProperties: INodeProperties[] = [
{
displayName: 'Optimize Response',
name: 'optimizeResponse',
type: 'boolean',
default: false,
noDataExpression: true,
description:
'Whether the optimize the tool response to reduce amount of data passed to the LLM that could lead to better result and reduce cost',
},
{
displayName: 'Expected Response Type',
name: 'responseType',
type: 'options',
displayOptions: {
show: {
optimizeResponse: [true],
},
},
options: [
{
name: 'JSON',
value: 'json',
},
{
name: 'HTML',
value: 'html',
},
{
name: 'Text',
value: 'text',
},
],
default: 'json',
},
{
displayName: 'Field Containing Data',
name: 'dataField',
type: 'string',
default: '',
placeholder: 'e.g. records',
description: 'Specify the name of the field in the response containing the data',
hint: 'leave blank to use whole response',
requiresDataPath: 'single',
displayOptions: {
show: {
optimizeResponse: [true],
responseType: ['json'],
},
},
},
{
displayName: 'Include Fields',
name: 'fieldsToInclude',
type: 'options',
description: 'What fields response object should include',
default: 'all',
displayOptions: {
show: {
optimizeResponse: [true],
responseType: ['json'],
},
},
options: [
{
name: 'All',
value: 'all',
description: 'Include all fields',
},
{
name: 'Selected',
value: 'selected',
description: 'Include only fields specified below',
},
{
name: 'Except',
value: 'except',
description: 'Exclude fields specified below',
},
],
},
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
placeholder: 'e.g. field1,field2',
description:
'Comma-separated list of the field names. Supports dot notation. You can drag the selected fields from the input panel.',
requiresDataPath: 'multiple',
displayOptions: {
show: {
optimizeResponse: [true],
responseType: ['json'],
},
hide: {
fieldsToInclude: ['all'],
},
},
},
{
displayName: 'Selector (CSS)',
name: 'cssSelector',
type: 'string',
description:
'Select specific element(e.g. body) or multiple elements(e.g. div) of chosen type in the response HTML.',
placeholder: 'e.g. body',
default: 'body',
displayOptions: {
show: {
optimizeResponse: [true],
responseType: ['html'],
},
},
},
{
displayName: 'Return Only Content',
name: 'onlyContent',
type: 'boolean',
default: false,
description:
'Whether to return only content of html elements, stripping html tags and attributes',
hint: 'Uses less tokens and may be easier for model to understand',
displayOptions: {
show: {
optimizeResponse: [true],
responseType: ['html'],
},
},
},
{
displayName: 'Elements To Omit',
name: 'elementsToOmit',
type: 'string',
displayOptions: {
show: {
optimizeResponse: [true],
responseType: ['html'],
onlyContent: [true],
},
},
default: '',
placeholder: 'e.g. img, .className, #ItemId',
description: 'Comma-separated list of selectors that would be excluded when extracting content',
},
{
displayName: 'Truncate Response',
name: 'truncateResponse',
type: 'boolean',
default: false,
hint: 'Helps save tokens',
displayOptions: {
show: {
optimizeResponse: [true],
responseType: ['text', 'html'],
},
},
},
{
displayName: 'Max Response Characters',
name: 'maxLength',
type: 'number',
default: 1000,
typeOptions: {
minValue: 1,
},
displayOptions: {
show: {
optimizeResponse: [true],
responseType: ['text', 'html'],
truncateResponse: [true],
},
},
},
];
@@ -0,0 +1,63 @@
import { updadeQueryParameterConfig } from '../GenericFunctions';
describe('updadeQueryParameterConfig', () => {
describe('version < 4.3 (legacy behavior)', () => {
const updateQueryParam = updadeQueryParameterConfig(4.2);
it('should set simple key-value pairs', () => {
const qs = {};
updateQueryParam(qs, 'key1', 'value1');
expect(qs).toEqual({ key1: 'value1' });
});
it('should overwrite existing values', () => {
const qs = { key1: 'oldValue' };
updateQueryParam(qs, 'key1', 'newValue');
expect(qs).toEqual({ key1: 'newValue' });
});
});
describe('version >= 4.3 (array behavior)', () => {
const updateQueryParam = updadeQueryParameterConfig(4.3);
it('should set initial value when key does not exist', () => {
const qs = {};
updateQueryParam(qs, 'key1', 'value1');
expect(qs).toEqual({ key1: 'value1' });
});
it('should create array when adding second value', () => {
const qs = { key1: 'value1' };
updateQueryParam(qs, 'key1', 'value2');
expect(qs).toEqual({ key1: ['value1', 'value2'] });
});
it('should append to existing array', () => {
const qs = { key1: ['value1', 'value2'] };
updateQueryParam(qs, 'key1', 'value3');
expect(qs).toEqual({ key1: ['value1', 'value2', 'value3'] });
});
it('should handle undefined values correctly', () => {
const qs = {};
updateQueryParam(qs, 'newKey', 'value');
expect(qs).toEqual({ newKey: 'value' });
});
});
describe('version boundary', () => {
it('should use legacy behavior for version 4.2', () => {
const updateQueryParam = updadeQueryParameterConfig(4.2);
const qs = { key: 'first' };
updateQueryParam(qs, 'key', 'second');
expect(qs.key).toBe('second');
});
it('should use array behavior for version 4.3', () => {
const updateQueryParam = updadeQueryParameterConfig(4.3);
const qs = { key: 'first' };
updateQueryParam(qs, 'key', 'second');
expect(qs.key).toEqual(['first', 'second']);
});
});
});
@@ -0,0 +1,29 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('Test Binary Data Download', () => {
const baseUrl = 'https://dummy.domain';
beforeAll(async () => {
nock(baseUrl)
.persist()
.get('/path/to/image.png')
.reply(200, Buffer.from('test'), { 'content-type': 'image/png' });
nock(baseUrl)
.persist()
.get('/path/to/text.txt')
.reply(200, Buffer.from('test'), { 'content-type': 'text/plain; charset=utf-8' });
nock(baseUrl)
.persist()
.get('/redirect-to-image')
.reply(302, {}, { location: baseUrl + '/path/to/image.png' });
nock(baseUrl).persist().get('/custom-content-disposition').reply(200, Buffer.from('testing'), {
'content-disposition': 'attachment; filename="testing.jpg"',
});
});
new NodeTestHarness().setupTests({ assertBinaryData: true });
});
@@ -0,0 +1,264 @@
{
"name": "Download as Binary Data",
"nodes": [
{
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"parameters": {},
"position": [580, 300]
},
{
"name": "HTTP Request (v1)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"parameters": {
"url": "https://dummy.domain/path/to/image.png",
"responseFormat": "file"
},
"position": [1020, -100]
},
{
"name": "HTTP Request (v2)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 2,
"parameters": {
"url": "https://dummy.domain/path/to/image.png",
"responseFormat": "file",
"options": {}
},
"position": [1020, 80]
},
{
"name": "HTTP Request (v3)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"parameters": {
"url": "https://dummy.domain/path/to/image.png",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
}
},
"position": [1020, 240]
},
{
"name": "HTTP Request (v4)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"parameters": {
"url": "https://dummy.domain/path/to/image.png",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
}
},
"position": [1020, 400]
},
{
"name": "Follow Redirect",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"parameters": {
"url": "https://dummy.domain/redirect-to-image",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
}
},
"position": [1020, 560]
},
{
"name": "Content Disposition",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"parameters": {
"url": "https://dummy.domain/custom-content-disposition",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
}
},
"position": [1020, 720]
},
{
"parameters": {
"url": "https://dummy.domain/path/to/text.txt",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
}
},
"name": "HTTP Request (v4)2",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [1680, 600],
"id": "665fef5c-6380-4bc0-be2a-430446b140ca"
}
],
"pinData": {
"HTTP Request (v1)": [
{
"binary": {
"data": {
"data": "dGVzdA==",
"mimeType": "image/png",
"fileType": "image",
"fileExtension": "png",
"fileName": "image.png",
"fileSize": "4 B"
}
},
"json": {}
}
],
"HTTP Request (v2)": [
{
"binary": {
"data": {
"data": "dGVzdA==",
"mimeType": "image/png",
"fileType": "image",
"fileExtension": "png",
"fileName": "image.png",
"fileSize": "4 B"
}
},
"json": {}
}
],
"HTTP Request (v3)": [
{
"binary": {
"data": {
"data": "dGVzdA==",
"mimeType": "image/png",
"fileType": "image",
"fileExtension": "png",
"fileName": "image.png",
"fileSize": "4 B"
}
},
"json": {}
}
],
"HTTP Request (v4)": [
{
"binary": {
"data": {
"data": "dGVzdA==",
"mimeType": "image/png",
"fileType": "image",
"fileExtension": "png",
"fileName": "image.png",
"fileSize": "4 B"
}
},
"json": {}
}
],
"Follow Redirect": [
{
"binary": {
"data": {
"data": "dGVzdA==",
"mimeType": "image/png",
"fileType": "image",
"fileExtension": "png",
"fileName": "image.png",
"fileSize": "4 B"
}
},
"json": {}
}
],
"Content Disposition": [
{
"binary": {
"data": {
"data": "dGVzdGluZw==",
"mimeType": "image/jpeg",
"fileType": "image",
"fileExtension": "jpg",
"fileName": "testing.jpg",
"fileSize": "7 B"
}
},
"json": {}
}
],
"HTTP Request (v4)2": [
{
"binary": {
"data": {
"data": "dGVzdA==",
"mimeType": "text/plain",
"fileType": "text",
"fileExtension": "txt",
"fileName": "text.txt",
"fileSize": "4 B"
}
},
"json": {}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "HTTP Request (v1)",
"type": "main",
"index": 0
},
{
"node": "HTTP Request (v2)",
"type": "main",
"index": 0
},
{
"node": "HTTP Request (v3)",
"type": "main",
"index": 0
},
{
"node": "HTTP Request (v4)",
"type": "main",
"index": 0
},
{
"node": "Follow Redirect",
"type": "main",
"index": 0
},
{
"node": "Content Disposition",
"type": "main",
"index": 0
},
{
"node": "HTTP Request (v4)2",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,19 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('Test Response Encoding', () => {
const baseUrl = 'https://dummy.domain';
const payload = Buffer.from(
'El rápido zorro marrón salta sobre el perro perezoso. ¡Qué bello día en París! Árbol, cañón, façade.',
'latin1',
);
beforeAll(async () => {
nock(baseUrl)
.persist()
.get('/index.html')
.reply(200, payload, { 'content-type': 'text/plain; charset=latin1' });
});
new NodeTestHarness().setupTests();
});
@@ -0,0 +1,78 @@
{
"name": "Response Encoding Test",
"nodes": [
{
"parameters": {},
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
180,
820
],
"id": "635fb102-a760-4b9e-836c-82e71bba7974"
},
{
"parameters": {
"url": "https://dummy.domain/index.html",
"options": {}
},
"name": "HTTP Request (v3)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
520,
720
],
"id": "eb243cfd-fbd6-41ef-935d-4ea98617355f"
},
{
"parameters": {
"url": "https://dummy.domain/index.html",
"options": {}
},
"name": "HTTP Request (v4)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
520,
920
],
"id": "cc2f185d-df6a-4fa3-b7f4-29f0dbad0f9b"
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "HTTP Request (v3)",
"type": "main",
"index": 0
},
{
"node": "HTTP Request (v4)",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"HTTP Request (v3)": [
{
"json": {
"data": "El rápido zorro marrón salta sobre el perro perezoso. ¡Qué bello día en París! Árbol, cañón, façade."
}
}
],
"HTTP Request (v4)": [
{
"json": {
"data": "El rápido zorro marrón salta sobre el perro perezoso. ¡Qué bello día en París! Árbol, cañón, façade."
}
}
]
}
}
@@ -0,0 +1,19 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('Test Quoted Response Encoding', () => {
const baseUrl = 'https://dummy.domain';
const payload = Buffer.from(
'El rápido zorro marrón salta sobre el perro perezoso. ¡Qué bello día en París! Árbol, cañón, façade.',
'latin1',
);
beforeAll(async () => {
nock(baseUrl)
.persist()
.get('/index.html')
.reply(200, payload, { 'content-type': 'text/plain; charset="latin1"' });
});
new NodeTestHarness().setupTests();
});
@@ -0,0 +1,69 @@
{
"name": "Response Encoding Test",
"nodes": [
{
"parameters": {},
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [180, 820],
"id": "635fb102-a760-4b9e-836c-82e71bba7974"
},
{
"parameters": {
"url": "https://dummy.domain/index.html",
"options": {}
},
"name": "HTTP Request (v3)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [520, 720],
"id": "eb243cfd-fbd6-41ef-935d-4ea98617355f"
},
{
"parameters": {
"url": "https://dummy.domain/index.html",
"options": {}
},
"name": "HTTP Request (v4)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [520, 920],
"id": "cc2f185d-df6a-4fa3-b7f4-29f0dbad0f9b"
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "HTTP Request (v3)",
"type": "main",
"index": 0
},
{
"node": "HTTP Request (v4)",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"HTTP Request (v3)": [
{
"json": {
"data": "El rápido zorro marrón salta sobre el perro perezoso. ¡Qué bello día en París! Árbol, cañón, façade."
}
}
],
"HTTP Request (v4)": [
{
"json": {
"data": "El rápido zorro marrón salta sobre el perro perezoso. ¡Qué bello día en París! Árbol, cañón, façade."
}
}
]
}
}
@@ -0,0 +1,185 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import { parse as parseUrl } from 'url';
describe('Test HTTP Request Node', () => {
const baseUrl = 'https://dummyjson.com';
beforeAll(async () => {
function getPaginationReturnData(this: nock.ReplyFnContext, limit = 10, skip = 0) {
const nextUrl = `${baseUrl}/users?skip=${skip + limit}&limit=${limit}`;
const response = [];
for (let i = skip; i < skip + limit; i++) {
if (i > 14) {
break;
}
response.push({
id: i,
});
}
if (!response.length) {
return [
404,
response,
{
'next-url': nextUrl,
'content-type': this.req.headers['content-type'] || 'application/json',
},
];
}
return [
200,
response,
{
'next-url': nextUrl,
'content-type': this.req.headers['content-type'] || 'application/json',
},
];
}
//GET
nock(baseUrl).get('/todos/1').reply(200, {
id: 1,
todo: 'Do something nice for someone I care about',
completed: true,
userId: 26,
});
nock(baseUrl).get('/todos/1').reply(200, {
id: 1,
todo: 'Do something nice for someone I care about',
completed: true,
userId: 26,
});
nock(baseUrl).matchHeader('Authorization', 'Bearer 12345').get('/todos/3').reply(200, {
id: 3,
todo: 'Watch a classic movie',
completed: false,
userId: 4,
});
nock(baseUrl)
.get('/todos?limit=2&skip=10')
.reply(200, {
todos: [
{
id: 11,
todo: "Text a friend I haven't talked to in a long time",
completed: false,
userId: 39,
},
{
id: 12,
todo: 'Organize pantry',
completed: true,
userId: 39,
},
],
total: 150,
skip: 10,
limit: 2,
});
//POST
nock(baseUrl)
.post('/todos/add', {
todo: 'Use DummyJSON in the project',
completed: false,
userId: '5',
})
.reply(200, {
id: 151,
todo: 'Use DummyJSON in the project',
completed: false,
userId: '5',
});
nock(baseUrl)
.post('/todos/add2', {
todo: 'Use DummyJSON in the project',
completed: false,
userId: 15,
})
.reply(200, {
id: 151,
todo: 'Use DummyJSON in the project',
completed: false,
userId: 15,
});
nock(baseUrl).get('/html').reply(200, '<html><body><h1>Test</h1></body></html>');
//PUT
nock(baseUrl).put('/todos/10', { userId: '42' }).reply(200, {
id: 10,
todo: 'Have a football scrimmage with some friends',
completed: false,
userId: '42',
});
//PATCH
nock(baseUrl)
.patch('/products/1', '{"title":"iPhone 12"}')
.reply(200, {
id: 1,
title: 'iPhone 12',
price: 549,
stock: 94,
rating: 4.69,
images: [
'https://i.dummyjson.com/data/products/1/1.jpg',
'https://i.dummyjson.com/data/products/1/2.jpg',
'https://i.dummyjson.com/data/products/1/3.jpg',
'https://i.dummyjson.com/data/products/1/4.jpg',
'https://i.dummyjson.com/data/products/1/thumbnail.jpg',
],
thumbnail: 'https://i.dummyjson.com/data/products/1/thumbnail.jpg',
description: 'An apple mobile which is nothing like apple',
brand: 'Apple',
category: 'smartphones',
});
//DELETE
nock(baseUrl).delete('/todos/1').reply(200, {
id: 1,
todo: 'Do something nice for someone I care about',
completed: true,
userId: 26,
isDeleted: true,
deletedOn: '2023-02-09T05:37:31.720Z',
});
// Pagination - GET
nock(baseUrl)
.persist()
.get('/users')
.query(true)
.reply(function (uri) {
const data = parseUrl(uri, true);
const limit = parseInt((data.query.limit as string) || '10', 10);
const skip = parseInt((data.query.skip as string) || '0', 10);
return getPaginationReturnData.call(this, limit, skip);
});
// Pagination - POST
nock(baseUrl)
.persist()
.post('/users')
.reply(function (_uri, body) {
let skip = 0;
let limit = 10;
if (typeof body === 'string') {
// Form data
skip = parseInt(body.split('name="skip"')[1].split('---')[0] ?? '0', 10);
limit = parseInt(body.split('name="limit"')[1].split('---')[0] ?? '0', 10);
} else {
skip = parseInt(body.skip ?? '0', 10);
limit = parseInt(body.limit ?? '10', 10);
}
return getPaginationReturnData.call(this, limit, skip);
});
});
new NodeTestHarness().setupTests();
});
@@ -0,0 +1,163 @@
import type { IExecuteFunctions, INodeTypeBaseDescription } from 'n8n-workflow';
import { HttpRequestV2 } from '../../V2/HttpRequestV2.node';
describe('HttpRequestV2', () => {
let node: HttpRequestV2;
let executeFunctions: IExecuteFunctions;
const baseUrl = 'http://example.com';
const options = {
redirect: '',
batching: { batch: { batchSize: 1, batchInterval: 1 } },
proxy: '',
timeout: '',
allowUnauthorizedCerts: '',
queryParameterArrays: '',
response: '',
lowercaseHeaders: '',
};
beforeEach(() => {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'HTTP Request',
name: 'httpRequest',
description: 'Makes an HTTP request and returns the response data',
group: [],
};
node = new HttpRequestV2(baseDescription);
executeFunctions = {
getInputData: jest.fn(),
getNodeParameter: jest.fn(),
getNode: jest.fn(() => {
return {
type: 'n8n-nodes-base.httpRequest',
typeVersion: 2,
};
}),
getCredentials: jest.fn(),
helpers: {
request: jest.fn(),
requestOAuth1: jest.fn(
async () =>
await Promise.resolve({
success: true,
}),
),
requestOAuth2: jest.fn(
async () =>
await Promise.resolve({
success: true,
}),
),
requestWithAuthentication: jest.fn(),
requestWithAuthenticationPaginated: jest.fn(),
assertBinaryData: jest.fn(),
getBinaryStream: jest.fn(),
getBinaryMetadata: jest.fn(),
binaryToString: jest.fn((buffer: Buffer) => {
return buffer.toString();
}),
prepareBinaryData: jest.fn(),
},
getContext: jest.fn(),
sendMessageToUI: jest.fn(),
continueOnFail: jest.fn(),
getMode: jest.fn(),
} as unknown as IExecuteFunctions;
});
describe('Authentication Handling', () => {
const authenticationTypes = [
{
genericCredentialType: 'httpBasicAuth',
credentials: { user: 'username', password: 'password' },
authField: 'auth',
authValue: { user: 'username', pass: 'password' },
},
{
genericCredentialType: 'httpBearerAuth',
credentials: { token: 'bearerToken123' },
authField: 'headers',
authValue: { Authorization: 'Bearer bearerToken123' },
},
{
genericCredentialType: 'httpDigestAuth',
credentials: { user: 'username', password: 'password' },
authField: 'auth',
authValue: { user: 'username', pass: 'password', sendImmediately: false },
},
{
genericCredentialType: 'httpHeaderAuth',
credentials: { name: 'Authorization', value: 'Bearer token' },
authField: 'headers',
authValue: { Authorization: 'Bearer token' },
},
{
genericCredentialType: 'httpQueryAuth',
credentials: { name: 'Token', value: 'secretToken' },
authField: 'qs',
authValue: { Token: 'secretToken' },
},
{
genericCredentialType: 'oAuth1Api',
credentials: { oauth_token: 'token', oauth_token_secret: 'secret' },
authField: 'oauth',
authValue: { oauth_token: 'token', oauth_token_secret: 'secret' },
},
{
genericCredentialType: 'oAuth2Api',
credentials: { access_token: 'accessToken' },
authField: 'auth',
authValue: { bearer: 'accessToken' },
},
];
it.each(authenticationTypes)(
'should handle $genericCredentialType authentication',
async ({ genericCredentialType, credentials, authField, authValue }) => {
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return baseUrl;
case 'authentication':
return 'genericCredentialType';
case 'genericAuthType':
return genericCredentialType;
case 'options':
return options;
case 'bodyParametersUi':
case 'headerParametersUi':
case 'queryParametersUi':
return { parameter: [] };
default:
return undefined;
}
});
(executeFunctions.getCredentials as jest.Mock).mockResolvedValue(credentials);
const response = {
success: true,
};
(executeFunctions.helpers.request as jest.Mock).mockResolvedValue(response);
const result = await node.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
if (genericCredentialType === 'oAuth1Api') {
expect(executeFunctions.helpers.requestOAuth1).toHaveBeenCalled();
} else if (genericCredentialType === 'oAuth2Api') {
expect(executeFunctions.helpers.requestOAuth2).toHaveBeenCalled();
} else {
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
expect.objectContaining({
[authField]: expect.objectContaining(authValue),
}),
);
}
},
);
});
});
@@ -0,0 +1,427 @@
import type { IExecuteFunctions, INodeTypeBaseDescription } from 'n8n-workflow';
import { HttpRequestV3 } from '../../V3/HttpRequestV3.node';
describe('HttpRequestV3', () => {
let node: HttpRequestV3;
let executeFunctions: IExecuteFunctions;
const baseUrl = 'http://example.com';
const options = {
redirect: '',
batching: { batch: { batchSize: 1, batchInterval: 1 } },
proxy: '',
timeout: '',
allowUnauthoridCerts: '',
queryParameterArrays: '',
response: '',
lowercaseHeaders: '',
};
beforeEach(() => {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'HTTP Request',
name: 'httpRequest',
description: 'Makes an HTTP request and returns the response data',
group: [],
};
node = new HttpRequestV3(baseDescription);
executeFunctions = {
getInputData: jest.fn(),
getNodeParameter: jest.fn(),
getNode: jest.fn(() => {
return {
type: 'n8n-nodes-base.httpRequest',
typeVersion: 3,
};
}),
getCredentials: jest.fn(),
helpers: {
request: jest.fn(),
requestOAuth1: jest.fn(
async () =>
await Promise.resolve({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: Buffer.from(JSON.stringify({ success: true })),
}),
),
requestOAuth2: jest.fn(
async () =>
await Promise.resolve({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: Buffer.from(JSON.stringify({ success: true })),
}),
),
requestWithAuthentication: jest.fn(),
requestWithAuthenticationPaginated: jest.fn(),
assertBinaryData: jest.fn(),
getBinaryStream: jest.fn(),
getBinaryMetadata: jest.fn(),
binaryToString: jest.fn((buffer: Buffer) => {
return buffer.toString();
}),
prepareBinaryData: jest.fn(),
},
getContext: jest.fn(),
sendMessageToUI: jest.fn(),
continueOnFail: jest.fn(),
getMode: jest.fn(),
} as unknown as IExecuteFunctions;
});
it('should make a GET request', async () => {
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return baseUrl;
case 'authentication':
return 'none';
case 'options':
return options;
default:
return undefined;
}
});
const response = {
headers: { 'content-type': 'application/json' },
body: Buffer.from(JSON.stringify({ success: true })),
};
(executeFunctions.helpers.request as jest.Mock).mockResolvedValue(response);
const result = await node.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
});
it('should handle authentication', async () => {
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return baseUrl;
case 'authentication':
return 'genericCredentialType';
case 'genericAuthType':
return 'httpBasicAuth';
case 'options':
return options;
default:
return undefined;
}
});
(executeFunctions.getCredentials as jest.Mock).mockResolvedValue({
user: 'username',
password: 'password',
});
const response = {
headers: { 'content-type': 'application/json' },
body: Buffer.from(JSON.stringify({ success: true })),
};
(executeFunctions.helpers.request as jest.Mock).mockResolvedValue(response);
const result = await node.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
expect.objectContaining({
auth: {
user: 'username',
pass: 'password',
},
}),
);
});
describe('Authentication Handling', () => {
const authenticationTypes = [
{
genericCredentialType: 'httpBasicAuth',
credentials: { user: 'username', password: 'password' },
authField: 'auth',
authValue: { user: 'username', pass: 'password' },
},
{
genericCredentialType: 'httpBearerAuth',
credentials: { token: 'bearerToken123' },
authField: 'headers',
authValue: { Authorization: 'Bearer bearerToken123' },
},
{
genericCredentialType: 'httpDigestAuth',
credentials: { user: 'username', password: 'password' },
authField: 'auth',
authValue: { user: 'username', pass: 'password', sendImmediately: false },
},
{
genericCredentialType: 'httpHeaderAuth',
credentials: { name: 'Authorization', value: 'Bearer token' },
authField: 'headers',
authValue: { Authorization: 'Bearer token' },
},
{
genericCredentialType: 'httpQueryAuth',
credentials: { name: 'Token', value: 'secretToken' },
authField: 'qs',
authValue: { Token: 'secretToken' },
},
{
genericCredentialType: 'oAuth1Api',
credentials: { oauth_token: 'token', oauth_token_secret: 'secret' },
authField: 'oauth',
authValue: { oauth_token: 'token', oauth_token_secret: 'secret' },
},
{
genericCredentialType: 'oAuth2Api',
credentials: { access_token: 'accessToken' },
authField: 'auth',
authValue: { bearer: 'accessToken' },
},
];
it.each(authenticationTypes)(
'should handle $genericCredentialType authentication',
async ({ genericCredentialType, credentials, authField, authValue }) => {
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return baseUrl;
case 'authentication':
return 'genericCredentialType';
case 'genericAuthType':
return genericCredentialType;
case 'options':
return options;
default:
return undefined;
}
});
(executeFunctions.getCredentials as jest.Mock).mockResolvedValue(credentials);
const response = {
headers: { 'content-type': 'application/json' },
body: Buffer.from(JSON.stringify({ success: true })),
};
(executeFunctions.helpers.request as jest.Mock).mockResolvedValue(response);
const result = await node.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
if (genericCredentialType === 'oAuth1Api') {
expect(executeFunctions.helpers.requestOAuth1).toHaveBeenCalled();
} else if (genericCredentialType === 'oAuth2Api') {
expect(executeFunctions.helpers.requestOAuth2).toHaveBeenCalled();
} else {
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
expect.objectContaining({
[authField]: expect.objectContaining(authValue),
}),
);
}
},
);
});
describe('URL Parameter Validation', () => {
it('should throw error when URL is undefined', async () => {
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return undefined;
case 'authentication':
return 'none';
case 'options':
return options;
default:
return undefined;
}
});
await expect(node.execute.call(executeFunctions)).rejects.toThrow(
'URL parameter must be a string, got undefined',
);
});
it('should throw error when URL is null', async () => {
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return null;
case 'authentication':
return 'none';
case 'options':
return options;
default:
return undefined;
}
});
await expect(node.execute.call(executeFunctions)).rejects.toThrow(
'URL parameter must be a string, got null',
);
});
it('should throw error when URL is a number', async () => {
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return 42;
case 'authentication':
return 'none';
case 'options':
return options;
default:
return undefined;
}
});
await expect(node.execute.call(executeFunctions)).rejects.toThrow(
'URL parameter must be a string, got number',
);
});
});
describe('Cross-Origin Redirects', () => {
it('should pass sendCredentialsOnCrossOriginRedirect = true to the request by default for node versions < 4.4', async () => {
(executeFunctions.getNode as jest.Mock).mockReturnValue({
typeVersion: 4.3,
});
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return baseUrl;
case 'authentication':
return 'genericCredentialType';
case 'genericAuthType':
return 'httpBasicAuth';
case 'options':
return options;
default:
return undefined;
}
});
(executeFunctions.getCredentials as jest.Mock).mockResolvedValue({
user: 'username',
password: 'password',
});
const response = {
headers: { 'content-type': 'application/json' },
body: Buffer.from(JSON.stringify({ success: true })),
};
(executeFunctions.helpers.request as jest.Mock).mockResolvedValue(response);
const result = await node.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
expect.objectContaining({
sendCredentialsOnCrossOriginRedirect: true,
}),
);
});
it('should pass sendCredentialsOnCrossOriginRedirect = false to the request by default for node versions >= 4.4', async () => {
(executeFunctions.getNode as jest.Mock).mockReturnValue({
typeVersion: 4.4,
});
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return baseUrl;
case 'authentication':
return 'genericCredentialType';
case 'genericAuthType':
return 'httpBasicAuth';
case 'options':
return options;
default:
return undefined;
}
});
(executeFunctions.getCredentials as jest.Mock).mockResolvedValue({
user: 'username',
password: 'password',
});
const response = {
headers: { 'content-type': 'application/json' },
body: Buffer.from(JSON.stringify({ success: true })),
};
(executeFunctions.helpers.request as jest.Mock).mockResolvedValue(response);
const result = await node.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
expect.objectContaining({
sendCredentialsOnCrossOriginRedirect: false,
}),
);
});
it('should use the sendCredentialsOnCrossOriginRedirect parameter to the request if provided', async () => {
(executeFunctions.getNode as jest.Mock).mockReturnValue({
typeVersion: 4.4,
});
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return baseUrl;
case 'authentication':
return 'genericCredentialType';
case 'genericAuthType':
return 'httpBasicAuth';
case 'options':
return { ...options, sendCredentialsOnCrossOriginRedirect: true };
default:
return undefined;
}
});
(executeFunctions.getCredentials as jest.Mock).mockResolvedValue({
user: 'username',
password: 'password',
});
const response = {
headers: { 'content-type': 'application/json' },
body: Buffer.from(JSON.stringify({ success: true })),
};
(executeFunctions.helpers.request as jest.Mock).mockResolvedValue(response);
const result = await node.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
expect.objectContaining({
sendCredentialsOnCrossOriginRedirect: true,
}),
);
});
});
});
@@ -0,0 +1,60 @@
{
"name": "http request test",
"nodes": [
{
"parameters": {},
"id": "12433cfb-74d9-4bf1-9afd-0ab9afc9ef19",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [820, 360]
},
{
"parameters": {
"method": "DELETE",
"url": "https://dummyjson.com/todos/1",
"options": {}
},
"id": "312e64ca-00bf-40e6-b21d-1f73930ef98c",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [1100, 360]
}
],
"pinData": {
"HTTP Request": [
{
"json": {
"id": 1,
"todo": "Do something nice for someone I care about",
"completed": true,
"userId": 26,
"isDeleted": true,
"deletedOn": "2023-02-09T05:37:31.720Z"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "b1c4f6ef-0d15-49f3-b46d-447671b1583e",
"id": "108",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}
@@ -0,0 +1,188 @@
{
"name": "HTTP Request test",
"nodes": [
{
"parameters": {},
"id": "3db51d12-a71b-4d0d-84db-1d4c46454c40",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
160,
720
]
},
{
"parameters": {
"url": "https://dummyjson.com/todos/1",
"options": {}
},
"id": "96f38d87-0bdd-420c-b981-26fd55d11cb2",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
460,
460
]
},
{
"parameters": {
"url": "https://dummyjson.com/todos/3",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer 12345"
}
]
},
"options": {}
},
"id": "85ca0a5b-3ff4-491d-ba51-990fdf2b757f",
"name": "HTTP Request fake header",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
460,
800
]
},
{
"parameters": {
"url": "https://dummyjson.com/todos",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "limit",
"value": "2"
},
{
"name": "skip",
"value": "10"
}
]
},
"options": {}
},
"id": "68d6e51a-66ea-45bf-928c-55efd2493cf0",
"name": "HTTP Request with query",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
460,
980
]
},
{
"parameters": {
"url": "https://dummyjson.com/todos/1",
"sendHeaders": true,
"options": {}
},
"id": "38ec1a50-7f0e-4749-822d-f26370b00694",
"name": "HTTP Request empty header",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
460,
640
]
}
],
"pinData": {
"HTTP Request": [
{
"json": {
"id": 1,
"todo": "Do something nice for someone I care about",
"completed": true,
"userId": 26
}
}
],
"HTTP Request with query": [
{
"json": {
"todos": [
{
"id": 11,
"todo": "Text a friend I haven't talked to in a long time",
"completed": false,
"userId": 39
},
{
"id": 12,
"todo": "Organize pantry",
"completed": true,
"userId": 39
}
],
"total": 150,
"skip": 10,
"limit": 2
}
}
],
"HTTP Request fake header": [
{
"json": {
"id": 3,
"todo": "Watch a classic movie",
"completed": false,
"userId": 4
}
}
],
"HTTP Request empty header": [
{
"json": {
"id": 1,
"todo": "Do something nice for someone I care about",
"completed": true,
"userId": 26
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
},
{
"node": "HTTP Request with query",
"type": "main",
"index": 0
},
{
"node": "HTTP Request fake header",
"type": "main",
"index": 0
},
{
"node": "HTTP Request empty header",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4"
},
"tags": []
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
{
"name": "http request test",
"nodes": [
{
"parameters": {},
"id": "12433cfb-74d9-4bf1-9afd-0ab9afc9ef19",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [820, 360]
},
{
"parameters": {
"method": "PATCH",
"url": "https://dummyjson.com/products/1",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"title\":\"iPhone 12\"}",
"options": {}
},
"id": "312e64ca-00bf-40e6-b21d-1f73930ef98c",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [1100, 360]
}
],
"pinData": {
"HTTP Request": [
{
"json": {
"id": 1,
"title": "iPhone 12",
"price": 549,
"stock": 94,
"rating": 4.69,
"images": [
"https://i.dummyjson.com/data/products/1/1.jpg",
"https://i.dummyjson.com/data/products/1/2.jpg",
"https://i.dummyjson.com/data/products/1/3.jpg",
"https://i.dummyjson.com/data/products/1/4.jpg",
"https://i.dummyjson.com/data/products/1/thumbnail.jpg"
],
"thumbnail": "https://i.dummyjson.com/data/products/1/thumbnail.jpg",
"description": "An apple mobile which is nothing like apple",
"brand": "Apple",
"category": "smartphones"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "a49ffcc8-e61f-4fcd-93c0-c1c422d14b6c",
"id": "108",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}
@@ -0,0 +1,105 @@
{
"name": "http request test",
"nodes": [
{
"parameters": {},
"id": "12433cfb-74d9-4bf1-9afd-0ab9afc9ef19",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [820, 360]
},
{
"parameters": {
"method": "POST",
"url": "https://dummyjson.com/todos/add",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "todo",
"value": "Use DummyJSON in the project"
},
{
"name": "completed",
"value": "={{ false }}"
},
{
"name": "userId",
"value": "5"
}
]
},
"options": {}
},
"id": "07670093-862f-403f-96a5-ddf7fdb0d225",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [1140, 200]
},
{
"parameters": {
"method": "POST",
"url": "https://dummyjson.com/todos/add2",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"todo\":\"Use DummyJSON in the project\",\"completed\":false,\"userId\":15}",
"options": {}
},
"id": "db088210-2204-422c-823a-101afa464384",
"name": "HTTP Request1",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [1140, 440]
}
],
"pinData": {
"HTTP Request": [
{
"json": {
"id": 151,
"todo": "Use DummyJSON in the project",
"completed": false,
"userId": "5"
}
}
],
"HTTP Request1": [
{
"json": {
"id": 151,
"todo": "Use DummyJSON in the project",
"completed": false,
"userId": 15
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
},
{
"node": "HTTP Request1",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "c5d9075a-6d1e-49d8-b16b-7df985ebda69",
"id": "108",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}
@@ -0,0 +1,73 @@
{
"name": "http request test",
"nodes": [
{
"parameters": {},
"id": "12433cfb-74d9-4bf1-9afd-0ab9afc9ef19",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
820,
360
]
},
{
"parameters": {
"method": "PUT",
"url": "https://dummyjson.com/todos/10",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "userId",
"value": "42"
}
]
},
"options": {}
},
"id": "07670093-862f-403f-96a5-ddf7fdb0d225",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
1100,
360
]
}
],
"pinData": {
"HTTP Request": [
{
"json": {
"id": 10,
"todo": "Have a football scrimmage with some friends",
"completed": false,
"userId": "42"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "209dd43e-fa03-4da7-94fb-cecf1974c5fe",
"id": "108",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}
@@ -0,0 +1,161 @@
{
"name": "HTTP Request Node: Continue using error output not working",
"nodes": [
{
"parameters": {},
"id": "6707decf-7ae3-46f1-8603-b0fe4844f240",
"name": "When clicking Execute workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [16, 560]
},
{
"parameters": {},
"id": "09b63a84-789d-4a31-b546-849ba47ed689",
"name": "Success path",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [464, 272]
},
{
"parameters": {
"method": "POST",
"url": "https://dummyjson.com/todos/1",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\n \"q\": \"abc\",\n}",
"options": {}
},
"id": "c6841eb1-7913-4c8d-8c9d-b88a908125ed",
"name": "Invalid JSON Body",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [240, 368],
"alwaysOutputData": false,
"onError": "continueErrorOutput"
},
{
"parameters": {},
"id": "21750b7e-c18a-43a5-a068-f8aa85d1cadf",
"name": "Success path1",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [464, 656]
},
{
"parameters": {
"url": "https://dummyjson.com/html",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "ae2891f0-1968-4f57-a1a0-87d6f6dab57d",
"name": "Invalid JSON Response",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [240, 752],
"alwaysOutputData": false,
"onError": "continueErrorOutput"
},
{
"parameters": {},
"id": "baa75f00-e0dd-40b2-b718-1e8311549a05",
"name": "Request body error",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [464, 464]
},
{
"parameters": {},
"id": "4733c47f-38c8-44a8-9d77-d9e733777cbf",
"name": "Response body error",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [464, 848]
}
],
"pinData": {
"Request body error": [
{
"json": {
"error": "JSON parameter needs to be valid JSON"
}
}
],
"Response body error": [
{
"json": {
"error": "Response body is not valid JSON. Change \"Response Format\" to \"Text\""
}
}
]
},
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "Invalid JSON Body",
"type": "main",
"index": 0
},
{
"node": "Invalid JSON Response",
"type": "main",
"index": 0
}
]
]
},
"Invalid JSON Body": {
"main": [
[
{
"node": "Success path",
"type": "main",
"index": 0
}
],
[
{
"node": "Request body error",
"type": "main",
"index": 0
}
]
]
},
"Invalid JSON Response": {
"main": [
[
{
"node": "Success path1",
"type": "main",
"index": 0
}
],
[
{
"node": "Response body error",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "c7026310-8c4f-4889-a45b-befebacc7dde",
"meta": {
"instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4"
},
"id": "2Zd3If2j9PglrHri",
"tags": []
}
@@ -0,0 +1,29 @@
import type { IBinaryData, IRequestOptions } from 'n8n-workflow';
import { setFilename } from '../../V3/utils/binaryData';
describe('setFilename', () => {
it('returns filename from URI if fileName is missing and URI ends with fileExtension', () => {
const preparedBinaryData = { fileExtension: 'png' } as IBinaryData;
const requestOptions = { uri: 'https://example.com/image.png' } as IRequestOptions;
expect(setFilename(preparedBinaryData, requestOptions, undefined)).toBe('image.png');
});
it('returns constructed filename if fileName is missing and URI does not end with fileExtension', () => {
const preparedBinaryData = { fileExtension: 'jpg' } as IBinaryData;
const requestOptions = { uri: 'https://example.com/image.png' } as IRequestOptions;
expect(setFilename(preparedBinaryData, requestOptions, 'response')).toBe('response.jpg');
});
it('returns constructed filename with default "data" if responseFileName is undefined', () => {
const preparedBinaryData = { fileExtension: 'txt' } as IBinaryData;
const requestOptions = { uri: 'https://example.com/file' } as IRequestOptions;
expect(setFilename(preparedBinaryData, requestOptions, undefined)).toBe('data.txt');
});
it('returns fileName if it exists', () => {
const preparedBinaryData = { fileName: 'myfile.pdf', fileExtension: 'pdf' } as IBinaryData;
const requestOptions = { uri: 'https://example.com/file.pdf' } as IRequestOptions;
expect(setFilename(preparedBinaryData, requestOptions, 'response')).toBe('myfile.pdf');
});
});
@@ -0,0 +1,33 @@
import { mimeTypeFromResponse } from '../../V3/utils/parse';
describe('mimeTypeFromResponse', () => {
it('should return undefined if input is undefined', () => {
expect(mimeTypeFromResponse(undefined)).toBeUndefined();
});
it('should return the mime type for a simple type', () => {
expect(mimeTypeFromResponse('image/png')).toBe('image/png');
});
it('should strip charset from content type', () => {
expect(mimeTypeFromResponse('text/html; charset=utf-8')).toBe('text/html');
});
it('should strip charset from content type', () => {
expect(mimeTypeFromResponse('text/plain; charset=utf-8')).toBe('text/plain');
});
it('should strip boundary from multipart content type', () => {
expect(mimeTypeFromResponse('multipart/form-data; boundary=ExampleBoundaryString')).toBe(
'multipart/form-data',
);
});
it('should handle content type with extra spaces', () => {
expect(mimeTypeFromResponse('application/json ; charset=utf-8')).toBe('application/json');
});
it('should handle content type with space before semicolon', () => {
expect(mimeTypeFromResponse('application/xml ;charset=utf-8')).toBe('application/xml');
});
});
@@ -0,0 +1,330 @@
import type {
ICredentialDataDecryptedObject,
INodeExecutionData,
INodeProperties,
IRequestOptions,
} from 'n8n-workflow';
import {
REDACTED,
prepareRequestBody,
sanitizeUiMessage,
setAgentOptions,
replaceNullValues,
getSecrets,
} from '../../GenericFunctions';
import type { BodyParameter, BodyParametersReducer } from '../../GenericFunctions';
describe('HTTP Node Utils', () => {
describe('prepareRequestBody', () => {
it('should call default reducer', async () => {
const bodyParameters: BodyParameter[] = [
{
name: 'foo.bar',
value: 'baz',
},
];
const defaultReducer: BodyParametersReducer = jest.fn();
await prepareRequestBody(bodyParameters, 'json', 3, defaultReducer);
expect(defaultReducer).toBeCalledTimes(1);
expect(defaultReducer).toBeCalledWith({}, { name: 'foo.bar', value: 'baz' });
});
it('should call process dot notations', async () => {
const bodyParameters: BodyParameter[] = [
{
name: 'foo.bar.spam',
value: 'baz',
},
];
const defaultReducer: BodyParametersReducer = jest.fn();
const result = await prepareRequestBody(bodyParameters, 'json', 4, defaultReducer);
expect(defaultReducer).toBeCalledTimes(0);
expect(result).toBeDefined();
expect(result).toEqual({ foo: { bar: { spam: 'baz' } } });
});
});
describe('setAgentOptions', () => {
it("should not have agentOptions as it's undefined", async () => {
const requestOptions: IRequestOptions = {
method: 'GET',
uri: 'https://example.com',
};
const sslCertificates = undefined;
setAgentOptions(requestOptions, sslCertificates);
expect(requestOptions).toEqual({
method: 'GET',
uri: 'https://example.com',
});
});
it('should have agentOptions set', async () => {
const requestOptions: IRequestOptions = {
method: 'GET',
uri: 'https://example.com',
};
const sslCertificates = {
ca: 'mock-ca',
};
setAgentOptions(requestOptions, sslCertificates);
expect(requestOptions).toStrictEqual({
method: 'GET',
uri: 'https://example.com',
agentOptions: {
ca: 'mock-ca',
},
});
});
});
describe('sanitizeUiMessage', () => {
it('should remove large Buffers', async () => {
const requestOptions: IRequestOptions = {
method: 'POST',
uri: 'https://example.com',
body: Buffer.alloc(900000),
};
expect(sanitizeUiMessage(requestOptions, {}).body).toEqual(
'Binary data got replaced with this text. Original was a Buffer with a size of 900000 bytes.',
);
});
it('should remove keys that contain sensitive data and do not modify requestOptions', async () => {
const requestOptions: IRequestOptions = {
method: 'POST',
uri: 'https://example.com',
body: { sessionToken: 'secret', other: 'foo' },
headers: { authorization: 'secret', other: 'foo' },
auth: { user: 'user', password: 'secret' },
};
expect(
sanitizeUiMessage(requestOptions, {
headers: ['authorization'],
body: ['sessionToken'],
auth: ['password'],
}),
).toEqual({
body: { sessionToken: REDACTED, other: 'foo' },
headers: { other: 'foo', authorization: REDACTED },
auth: { user: 'user', password: REDACTED },
method: 'POST',
uri: 'https://example.com',
});
expect(requestOptions).toEqual({
method: 'POST',
uri: 'https://example.com',
body: { sessionToken: 'secret', other: 'foo' },
headers: { authorization: 'secret', other: 'foo' },
auth: { user: 'user', password: 'secret' },
});
});
it('should remove secrets', async () => {
const requestOptions: IRequestOptions = {
method: 'POST',
uri: 'https://example.com',
body: { nested: { secret: 'secretAccessToken' } },
headers: { authorization: 'secretAccessToken', other: 'foo' },
};
const sanitizedRequest = sanitizeUiMessage(requestOptions, {}, ['secretAccessToken']);
expect(sanitizedRequest).toEqual({
body: {
nested: {
secret: REDACTED,
},
},
headers: { authorization: REDACTED, other: 'foo' },
method: 'POST',
uri: 'https://example.com',
});
});
const headersToTest = [
'authorization',
'x-api-key',
'x-auth-token',
'cookie',
'proxy-authorization',
'sslclientcert',
];
headersToTest.forEach((header) => {
it(`should redact the ${header} header when the key is lowercase`, () => {
const requestOptions: IRequestOptions = {
method: 'POST',
uri: 'https://example.com',
body: { sessionToken: 'secret', other: 'foo' },
headers: { [header]: 'some-sensitive-token', other: 'foo' },
auth: { user: 'user', password: 'secret' },
};
const sanitizedRequest = sanitizeUiMessage(requestOptions, {});
expect(sanitizedRequest.headers).toEqual({ [header]: REDACTED, other: 'foo' });
});
it(`should redact the ${header} header when the key is uppercase`, () => {
const requestOptions: IRequestOptions = {
method: 'POST',
uri: 'https://example.com',
body: { sessionToken: 'secret', other: 'foo' },
headers: { [header.toUpperCase()]: 'some-sensitive-token', other: 'foo' },
auth: { user: 'user', password: 'secret' },
};
const sanitizedRequest = sanitizeUiMessage(requestOptions, {});
expect(sanitizedRequest.headers).toEqual({
[header.toUpperCase()]: REDACTED,
other: 'foo',
});
});
});
it('should leave headers unchanged if Authorization header is not present', () => {
const requestOptions: IRequestOptions = {
method: 'POST',
uri: 'https://example.com',
body: { sessionToken: 'secret', other: 'foo' },
headers: { other: 'foo' },
auth: { user: 'user', password: 'secret' },
};
const sanitizedRequest = sanitizeUiMessage(requestOptions, {});
expect(sanitizedRequest.headers).toEqual({ other: 'foo' });
});
it('should handle case when headers are undefined', () => {
const requestOptions: IRequestOptions = {};
const sanitizedRequest = sanitizeUiMessage(requestOptions, {});
expect(sanitizedRequest.headers).toBeUndefined();
});
});
describe('replaceNullValues', () => {
it('should replace null json with an empty object', () => {
const item: INodeExecutionData = {
json: {},
};
const result = replaceNullValues(item);
expect(result.json).toEqual({});
});
it('should not modify json if it is already an object', () => {
const jsonObject = { key: 'value' };
const item: INodeExecutionData = { json: jsonObject };
const result = replaceNullValues(item);
expect(result.json).toBe(jsonObject);
});
});
describe('getSecrets', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should return secrets for sensitive properties', () => {
const properties: INodeProperties[] = [
{
displayName: 'Api Key',
name: 'apiKey',
typeOptions: { password: true },
type: 'string',
default: undefined,
},
{
displayName: 'Username',
name: 'username',
type: 'string',
default: undefined,
},
];
const credentials: ICredentialDataDecryptedObject = {
apiKey: 'sensitive-api-key',
username: 'user123',
};
const secrets = getSecrets(properties, credentials);
expect(secrets).toEqual(['sensitive-api-key']);
});
it('should not return non-sensitive properties', () => {
const properties: INodeProperties[] = [
{
displayName: 'Username',
name: 'username',
type: 'string',
default: undefined,
},
];
const credentials: ICredentialDataDecryptedObject = {
username: 'user123',
};
const secrets = getSecrets(properties, credentials);
expect(secrets).toEqual([]);
});
it('should not include non-string values in sensitive properties', () => {
const properties: INodeProperties[] = [
{
displayName: 'ApiKey',
name: 'apiKey',
typeOptions: { password: true },
type: 'string',
default: undefined,
},
];
const credentials: ICredentialDataDecryptedObject = {
apiKey: 12345,
};
const secrets = getSecrets(properties, credentials);
expect(secrets).toEqual([]);
});
it('should return an empty array if properties and credentials are empty', () => {
const properties: INodeProperties[] = [];
const credentials: ICredentialDataDecryptedObject = {};
const secrets = getSecrets(properties, credentials);
expect(secrets).toEqual([]);
});
it('should not include null or undefined values in sensitive properties', () => {
const properties: INodeProperties[] = [
{
displayName: 'ApiKey',
name: 'apiKey',
typeOptions: { password: true },
type: 'string',
default: undefined,
},
];
const credentials: ICredentialDataDecryptedObject = {
apiKey: {},
};
const secrets = getSecrets(properties, credentials);
expect(secrets).toEqual([]);
});
});
});