mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-26 14:25:35 +02:00
1567 lines
46 KiB
TypeScript
1567 lines
46 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
/* eslint-disable @typescript-eslint/no-shadow */
|
|
import type {
|
|
ClientOAuth2Options,
|
|
ClientOAuth2RequestObject,
|
|
ClientOAuth2TokenData,
|
|
OAuth2CredentialData,
|
|
} from '@n8n/client-oauth2';
|
|
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
|
import { Container } from '@n8n/di';
|
|
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
|
import axios from 'axios';
|
|
import crypto, { createHmac } from 'crypto';
|
|
import FormData from 'form-data';
|
|
import { IncomingMessage } from 'http';
|
|
import { Agent, type AgentOptions } from 'https';
|
|
import get from 'lodash/get';
|
|
import isEmpty from 'lodash/isEmpty';
|
|
import merge from 'lodash/merge';
|
|
import pick from 'lodash/pick';
|
|
import type {
|
|
IAdditionalCredentialOptions,
|
|
IAllExecuteFunctions,
|
|
ICredentialDataDecryptedObject,
|
|
IDataObject,
|
|
IExecuteData,
|
|
IExecuteFunctions,
|
|
IHttpRequestOptions,
|
|
IN8nHttpFullResponse,
|
|
IN8nHttpResponse,
|
|
INode,
|
|
INodeExecutionData,
|
|
IOAuth2Options,
|
|
IPairedItemData,
|
|
IPollFunctions,
|
|
IRequestOptions,
|
|
IRunExecutionData,
|
|
ITriggerFunctions,
|
|
IWebhookDescription,
|
|
IWorkflowDataProxyAdditionalKeys,
|
|
IWorkflowExecuteAdditionalData,
|
|
NodeExecutionWithMetadata,
|
|
NodeParameterValueType,
|
|
PaginationOptions,
|
|
RequestHelperFunctions,
|
|
Workflow,
|
|
WorkflowActivateMode,
|
|
WorkflowExecuteMode,
|
|
SSHTunnelFunctions,
|
|
WebhookType,
|
|
SchedulingFunctions,
|
|
} from 'n8n-workflow';
|
|
import {
|
|
NodeApiError,
|
|
NodeHelpers,
|
|
NodeOperationError,
|
|
NodeSslError,
|
|
deepCopy,
|
|
isObjectEmpty,
|
|
ExecutionBaseError,
|
|
jsonParse,
|
|
ApplicationError,
|
|
sleep,
|
|
} from 'n8n-workflow';
|
|
import type { Token } from 'oauth-1.0a';
|
|
import clientOAuth1 from 'oauth-1.0a';
|
|
import { stringify } from 'qs';
|
|
import { Readable } from 'stream';
|
|
import url, { URL, URLSearchParams } from 'url';
|
|
|
|
import { Logger } from '@/logging/logger';
|
|
|
|
// eslint-disable-next-line import/no-cycle
|
|
import {
|
|
binaryToString,
|
|
parseIncomingMessage,
|
|
parseRequestObject,
|
|
PollContext,
|
|
TriggerContext,
|
|
} from './execution-engine/node-execution-context';
|
|
import { ScheduledTaskManager } from './execution-engine/scheduled-task-manager';
|
|
import { SSHClientsManager } from './execution-engine/ssh-clients-manager';
|
|
import type { IResponseError } from './interfaces';
|
|
|
|
axios.defaults.timeout = 300000;
|
|
// Prevent axios from adding x-form-www-urlencoded headers by default
|
|
axios.defaults.headers.post = {};
|
|
axios.defaults.headers.put = {};
|
|
axios.defaults.headers.patch = {};
|
|
axios.defaults.paramsSerializer = (params) => {
|
|
if (params instanceof URLSearchParams) {
|
|
return params.toString();
|
|
}
|
|
return stringify(params, { arrayFormat: 'indices' });
|
|
};
|
|
axios.interceptors.request.use((config) => {
|
|
// If no content-type is set by us, prevent axios from force-setting the content-type to `application/x-www-form-urlencoded`
|
|
if (config.data === undefined) {
|
|
config.headers.setContentType(false, false);
|
|
}
|
|
return config;
|
|
});
|
|
|
|
export const validateUrl = (url?: string): boolean => {
|
|
if (!url) return false;
|
|
|
|
try {
|
|
new URL(url);
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
function searchForHeader(config: AxiosRequestConfig, headerName: string) {
|
|
if (config.headers === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
const headerNames = Object.keys(config.headers);
|
|
headerName = headerName.toLowerCase();
|
|
return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName);
|
|
}
|
|
|
|
const getHostFromRequestObject = (
|
|
requestObject: Partial<{
|
|
url: string;
|
|
uri: string;
|
|
baseURL: string;
|
|
}>,
|
|
): string | null => {
|
|
try {
|
|
const url = (requestObject.url ?? requestObject.uri) as string;
|
|
return new URL(url, requestObject.baseURL).hostname;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getBeforeRedirectFn =
|
|
(agentOptions: AgentOptions, axiosConfig: AxiosRequestConfig) =>
|
|
(redirectedRequest: Record<string, any>) => {
|
|
const redirectAgent = new Agent({
|
|
...agentOptions,
|
|
servername: redirectedRequest.hostname,
|
|
});
|
|
redirectedRequest.agent = redirectAgent;
|
|
redirectedRequest.agents.https = redirectAgent;
|
|
|
|
if (axiosConfig.headers?.Authorization) {
|
|
redirectedRequest.headers.Authorization = axiosConfig.headers.Authorization;
|
|
}
|
|
if (axiosConfig.auth) {
|
|
redirectedRequest.auth = `${axiosConfig.auth.username}:${axiosConfig.auth.password}`;
|
|
}
|
|
};
|
|
|
|
function digestAuthAxiosConfig(
|
|
axiosConfig: AxiosRequestConfig,
|
|
response: AxiosResponse,
|
|
auth: AxiosRequestConfig['auth'],
|
|
): AxiosRequestConfig {
|
|
const authDetails = response.headers['www-authenticate']
|
|
.split(',')
|
|
.map((v: string) => v.split('='));
|
|
if (authDetails) {
|
|
const nonceCount = '000000001';
|
|
const cnonce = crypto.randomBytes(24).toString('hex');
|
|
const realm: string = authDetails
|
|
.find((el: any) => el[0].toLowerCase().indexOf('realm') > -1)[1]
|
|
.replace(/"/g, '');
|
|
// If authDetails does not have opaque, we should not add it to authorization.
|
|
const opaqueKV = authDetails.find((el: any) => el[0].toLowerCase().indexOf('opaque') > -1);
|
|
const opaque: string = opaqueKV ? opaqueKV[1].replace(/"/g, '') : undefined;
|
|
const nonce: string = authDetails
|
|
.find((el: any) => el[0].toLowerCase().indexOf('nonce') > -1)[1]
|
|
.replace(/"/g, '');
|
|
const ha1 = crypto
|
|
.createHash('md5')
|
|
.update(`${auth?.username as string}:${realm}:${auth?.password as string}`)
|
|
.digest('hex');
|
|
const urlURL = new url.URL(axios.getUri(axiosConfig));
|
|
const path = urlURL.pathname + urlURL.search;
|
|
const ha2 = crypto
|
|
.createHash('md5')
|
|
.update(`${axiosConfig.method ?? 'GET'}:${path}`)
|
|
.digest('hex');
|
|
const response = crypto
|
|
.createHash('md5')
|
|
.update(`${ha1}:${nonce}:${nonceCount}:${cnonce}:auth:${ha2}`)
|
|
.digest('hex');
|
|
let authorization =
|
|
`Digest username="${auth?.username as string}",realm="${realm}",` +
|
|
`nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",` +
|
|
`response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
|
|
// Only when opaque exists, add it to authorization.
|
|
if (opaque) {
|
|
authorization += `,opaque="${opaque}"`;
|
|
}
|
|
if (axiosConfig.headers) {
|
|
axiosConfig.headers.authorization = authorization;
|
|
} else {
|
|
axiosConfig.headers = { authorization };
|
|
}
|
|
}
|
|
return axiosConfig;
|
|
}
|
|
|
|
export async function invokeAxios(
|
|
axiosConfig: AxiosRequestConfig,
|
|
authOptions: IRequestOptions['auth'] = {},
|
|
) {
|
|
try {
|
|
return await axios(axiosConfig);
|
|
} catch (error) {
|
|
if (authOptions.sendImmediately !== false || !(error instanceof axios.AxiosError)) throw error;
|
|
// for digest-auth
|
|
const { response } = error;
|
|
if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) {
|
|
throw error;
|
|
}
|
|
const { auth } = axiosConfig;
|
|
delete axiosConfig.auth;
|
|
axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth);
|
|
return await axios(axiosConfig);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @deprecated This is only used by legacy request helpers, that are also deprecated
|
|
*/
|
|
export async function proxyRequestToAxios(
|
|
workflow: Workflow | undefined,
|
|
additionalData: IWorkflowExecuteAdditionalData | undefined,
|
|
node: INode | undefined,
|
|
uriOrObject: string | IRequestOptions,
|
|
options?: IRequestOptions,
|
|
): Promise<any> {
|
|
let axiosConfig: AxiosRequestConfig = {
|
|
maxBodyLength: Infinity,
|
|
maxContentLength: Infinity,
|
|
};
|
|
let configObject: IRequestOptions;
|
|
if (typeof uriOrObject === 'string') {
|
|
configObject = { uri: uriOrObject, ...options };
|
|
} else {
|
|
configObject = uriOrObject ?? {};
|
|
}
|
|
|
|
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
|
|
|
|
try {
|
|
const response = await invokeAxios(axiosConfig, configObject.auth);
|
|
let body = response.data;
|
|
if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
|
|
parseIncomingMessage(body);
|
|
} else if (body === '') {
|
|
body = axiosConfig.responseType === 'arraybuffer' ? Buffer.alloc(0) : undefined;
|
|
}
|
|
await additionalData?.hooks?.runHook('nodeFetchedData', [workflow?.id, node]);
|
|
return configObject.resolveWithFullResponse
|
|
? {
|
|
body,
|
|
headers: { ...response.headers },
|
|
statusCode: response.status,
|
|
statusMessage: response.statusText,
|
|
request: response.request,
|
|
}
|
|
: body;
|
|
} catch (error) {
|
|
const { config, response } = error;
|
|
|
|
// Axios hydrates the original error with more data. We extract them.
|
|
// https://github.com/axios/axios/blob/master/lib/core/enhanceError.js
|
|
// Note: `code` is ignored as it's an expected part of the errorData.
|
|
if (error.isAxiosError) {
|
|
error.config = error.request = undefined;
|
|
error.options = pick(config ?? {}, ['url', 'method', 'data', 'headers']);
|
|
if (response) {
|
|
Container.get(Logger).debug('Request proxied to Axios failed', { status: response.status });
|
|
let responseData = response.data;
|
|
|
|
if (Buffer.isBuffer(responseData) || responseData instanceof Readable) {
|
|
responseData = await binaryToString(responseData);
|
|
}
|
|
|
|
if (configObject.simple === false) {
|
|
if (configObject.resolveWithFullResponse) {
|
|
return {
|
|
body: responseData,
|
|
headers: response.headers,
|
|
statusCode: response.status,
|
|
statusMessage: response.statusText,
|
|
};
|
|
} else {
|
|
return responseData;
|
|
}
|
|
}
|
|
|
|
error.message = `${response.status as number} - ${JSON.stringify(responseData)}`;
|
|
throw Object.assign(error, {
|
|
statusCode: response.status,
|
|
/**
|
|
* Axios adds `status` when serializing, causing `status` to be available only to the client.
|
|
* Hence we add it explicitly to allow the backend to use it when resolving expressions.
|
|
*/
|
|
status: response.status,
|
|
error: responseData,
|
|
response: pick(response, ['headers', 'status', 'statusText']),
|
|
});
|
|
} else if ('rejectUnauthorized' in configObject && error.code?.includes('CERT')) {
|
|
throw new NodeSslError(error);
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line complexity
|
|
function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequestConfig {
|
|
// Destructure properties with the same name first.
|
|
const { headers, method, timeout, auth, proxy, url } = n8nRequest;
|
|
|
|
const axiosRequest: AxiosRequestConfig = {
|
|
headers: headers ?? {},
|
|
method,
|
|
timeout,
|
|
auth,
|
|
proxy,
|
|
url,
|
|
maxBodyLength: Infinity,
|
|
maxContentLength: Infinity,
|
|
} as AxiosRequestConfig;
|
|
|
|
axiosRequest.params = n8nRequest.qs;
|
|
|
|
if (n8nRequest.abortSignal) {
|
|
axiosRequest.signal = n8nRequest.abortSignal;
|
|
}
|
|
|
|
if (n8nRequest.baseURL !== undefined) {
|
|
axiosRequest.baseURL = n8nRequest.baseURL;
|
|
}
|
|
|
|
if (n8nRequest.disableFollowRedirect === true) {
|
|
axiosRequest.maxRedirects = 0;
|
|
}
|
|
|
|
if (n8nRequest.encoding !== undefined) {
|
|
axiosRequest.responseType = n8nRequest.encoding;
|
|
}
|
|
|
|
const host = getHostFromRequestObject(n8nRequest);
|
|
const agentOptions: AgentOptions = {};
|
|
if (host) {
|
|
agentOptions.servername = host;
|
|
}
|
|
if (n8nRequest.skipSslCertificateValidation === true) {
|
|
agentOptions.rejectUnauthorized = false;
|
|
}
|
|
axiosRequest.httpsAgent = new Agent(agentOptions);
|
|
|
|
axiosRequest.beforeRedirect = getBeforeRedirectFn(agentOptions, axiosRequest);
|
|
|
|
if (n8nRequest.arrayFormat !== undefined) {
|
|
axiosRequest.paramsSerializer = (params) => {
|
|
return stringify(params, { arrayFormat: n8nRequest.arrayFormat });
|
|
};
|
|
}
|
|
|
|
const { body } = n8nRequest;
|
|
if (body) {
|
|
// Let's add some useful header standards here.
|
|
const existingContentTypeHeaderKey = searchForHeader(axiosRequest, 'content-type');
|
|
if (existingContentTypeHeaderKey === undefined) {
|
|
axiosRequest.headers = axiosRequest.headers || {};
|
|
// We are only setting content type headers if the user did
|
|
// not set it already manually. We're not overriding, even if it's wrong.
|
|
if (body instanceof FormData) {
|
|
axiosRequest.headers = {
|
|
...axiosRequest.headers,
|
|
...body.getHeaders(),
|
|
};
|
|
} else if (body instanceof URLSearchParams) {
|
|
axiosRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
}
|
|
} else if (
|
|
axiosRequest.headers?.[existingContentTypeHeaderKey] === 'application/x-www-form-urlencoded'
|
|
) {
|
|
axiosRequest.data = new URLSearchParams(n8nRequest.body as Record<string, string>);
|
|
}
|
|
// if there is a body and it's empty (does not have properties),
|
|
// make sure not to send anything in it as some services fail when
|
|
// sending GET request with empty body.
|
|
if (typeof body === 'string' || (typeof body === 'object' && !isObjectEmpty(body))) {
|
|
axiosRequest.data = body;
|
|
}
|
|
}
|
|
|
|
if (n8nRequest.json) {
|
|
const key = searchForHeader(axiosRequest, 'accept');
|
|
// If key exists, then the user has set both accept
|
|
// header and the json flag. Header should take precedence.
|
|
if (!key) {
|
|
axiosRequest.headers = {
|
|
...axiosRequest.headers,
|
|
Accept: 'application/json',
|
|
};
|
|
}
|
|
}
|
|
|
|
const userAgentHeader = searchForHeader(axiosRequest, 'user-agent');
|
|
// If key exists, then the user has set both accept
|
|
// header and the json flag. Header should take precedence.
|
|
if (!userAgentHeader) {
|
|
axiosRequest.headers = {
|
|
...axiosRequest.headers,
|
|
'User-Agent': 'n8n',
|
|
};
|
|
}
|
|
|
|
if (n8nRequest.ignoreHttpStatusErrors) {
|
|
axiosRequest.validateStatus = () => true;
|
|
}
|
|
|
|
return axiosRequest;
|
|
}
|
|
|
|
const NoBodyHttpMethods = ['GET', 'HEAD', 'OPTIONS'];
|
|
|
|
/** Remove empty request body on GET, HEAD, and OPTIONS requests */
|
|
export const removeEmptyBody = (requestOptions: IHttpRequestOptions | IRequestOptions) => {
|
|
const method = requestOptions.method || 'GET';
|
|
if (NoBodyHttpMethods.includes(method) && isEmpty(requestOptions.body)) {
|
|
delete requestOptions.body;
|
|
}
|
|
};
|
|
|
|
export async function httpRequest(
|
|
requestOptions: IHttpRequestOptions,
|
|
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
|
removeEmptyBody(requestOptions);
|
|
|
|
const axiosRequest = convertN8nRequestToAxios(requestOptions);
|
|
if (
|
|
axiosRequest.data === undefined ||
|
|
(axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET')
|
|
) {
|
|
delete axiosRequest.data;
|
|
}
|
|
|
|
const result = await invokeAxios(axiosRequest, requestOptions.auth);
|
|
|
|
if (requestOptions.returnFullResponse) {
|
|
return {
|
|
body: result.data,
|
|
headers: result.headers,
|
|
statusCode: result.status,
|
|
statusMessage: result.statusText,
|
|
};
|
|
}
|
|
|
|
return result.data;
|
|
}
|
|
|
|
export function applyPaginationRequestData(
|
|
requestData: IRequestOptions,
|
|
paginationRequestData: PaginationOptions['request'],
|
|
): IRequestOptions {
|
|
const preparedPaginationData: Partial<IRequestOptions> = {
|
|
...paginationRequestData,
|
|
uri: paginationRequestData.url,
|
|
};
|
|
|
|
if ('formData' in requestData) {
|
|
preparedPaginationData.formData = paginationRequestData.body;
|
|
delete preparedPaginationData.body;
|
|
} else if ('form' in requestData) {
|
|
preparedPaginationData.form = paginationRequestData.body;
|
|
delete preparedPaginationData.body;
|
|
}
|
|
|
|
return merge({}, requestData, preparedPaginationData);
|
|
}
|
|
|
|
/**
|
|
* Makes a request using OAuth data for authentication
|
|
*
|
|
* @param {(IHttpRequestOptions | IRequestOptions)} requestOptions
|
|
*
|
|
*/
|
|
export async function requestOAuth2(
|
|
this: IAllExecuteFunctions,
|
|
credentialsType: string,
|
|
requestOptions: IHttpRequestOptions | IRequestOptions,
|
|
node: INode,
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
|
oAuth2Options?: IOAuth2Options,
|
|
isN8nRequest = false,
|
|
) {
|
|
removeEmptyBody(requestOptions);
|
|
|
|
const credentials = (await this.getCredentials(
|
|
credentialsType,
|
|
)) as unknown as OAuth2CredentialData;
|
|
|
|
// Only the OAuth2 with authorization code grant needs connection
|
|
if (credentials.grantType === 'authorizationCode' && credentials.oauthTokenData === undefined) {
|
|
throw new ApplicationError('OAuth credentials not connected');
|
|
}
|
|
|
|
const oAuthClient = new ClientOAuth2({
|
|
clientId: credentials.clientId,
|
|
clientSecret: credentials.clientSecret,
|
|
accessTokenUri: credentials.accessTokenUrl,
|
|
scopes: (credentials.scope as string).split(' '),
|
|
ignoreSSLIssues: credentials.ignoreSSLIssues,
|
|
authentication: credentials.authentication ?? 'header',
|
|
});
|
|
|
|
let oauthTokenData = credentials.oauthTokenData as ClientOAuth2TokenData;
|
|
// if it's the first time using the credentials, get the access token and save it into the DB.
|
|
if (
|
|
credentials.grantType === 'clientCredentials' &&
|
|
(oauthTokenData === undefined ||
|
|
Object.keys(oauthTokenData).length === 0 ||
|
|
oauthTokenData.access_token === '') // stub
|
|
) {
|
|
const { data } = await oAuthClient.credentials.getToken();
|
|
// Find the credentials
|
|
if (!node.credentials?.[credentialsType]) {
|
|
throw new ApplicationError('Node does not have credential type', {
|
|
extra: { nodeName: node.name },
|
|
tags: { credentialType: credentialsType },
|
|
});
|
|
}
|
|
|
|
const nodeCredentials = node.credentials[credentialsType];
|
|
credentials.oauthTokenData = data;
|
|
|
|
// Save the refreshed token
|
|
await additionalData.credentialsHelper.updateCredentials(
|
|
nodeCredentials,
|
|
credentialsType,
|
|
credentials as unknown as ICredentialDataDecryptedObject,
|
|
);
|
|
|
|
oauthTokenData = data;
|
|
}
|
|
|
|
const accessToken =
|
|
get(oauthTokenData, oAuth2Options?.property as string) || oauthTokenData.accessToken;
|
|
const refreshToken = oauthTokenData.refreshToken;
|
|
const token = oAuthClient.createToken(
|
|
{
|
|
...oauthTokenData,
|
|
...(accessToken ? { access_token: accessToken } : {}),
|
|
...(refreshToken ? { refresh_token: refreshToken } : {}),
|
|
},
|
|
oAuth2Options?.tokenType || oauthTokenData.tokenType,
|
|
);
|
|
|
|
(requestOptions as IRequestOptions).rejectUnauthorized = !credentials.ignoreSSLIssues;
|
|
|
|
// Signs the request by adding authorization headers or query parameters depending
|
|
// on the token-type used.
|
|
const newRequestOptions = token.sign(requestOptions as ClientOAuth2RequestObject);
|
|
const newRequestHeaders = (newRequestOptions.headers = newRequestOptions.headers ?? {});
|
|
// If keep bearer is false remove the it from the authorization header
|
|
if (oAuth2Options?.keepBearer === false && typeof newRequestHeaders.Authorization === 'string') {
|
|
newRequestHeaders.Authorization = newRequestHeaders.Authorization.split(' ')[1];
|
|
}
|
|
if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
|
|
Object.assign(newRequestHeaders, {
|
|
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
|
|
});
|
|
}
|
|
if (isN8nRequest) {
|
|
return await this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => {
|
|
if (error.response?.status === 401) {
|
|
this.logger.debug(
|
|
`OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`,
|
|
);
|
|
const tokenRefreshOptions: IDataObject = {};
|
|
if (oAuth2Options?.includeCredentialsOnRefreshOnBody) {
|
|
const body: IDataObject = {
|
|
client_id: credentials.clientId,
|
|
...(credentials.grantType === 'authorizationCode' && {
|
|
client_secret: credentials.clientSecret as string,
|
|
}),
|
|
};
|
|
tokenRefreshOptions.body = body;
|
|
tokenRefreshOptions.headers = {
|
|
Authorization: '',
|
|
};
|
|
}
|
|
|
|
let newToken;
|
|
|
|
this.logger.debug(
|
|
`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`,
|
|
);
|
|
// if it's OAuth2 with client credentials grant type, get a new token
|
|
// instead of refreshing it.
|
|
if (credentials.grantType === 'clientCredentials') {
|
|
newToken = await token.client.credentials.getToken();
|
|
} else {
|
|
newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options);
|
|
}
|
|
|
|
this.logger.debug(
|
|
`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`,
|
|
);
|
|
|
|
credentials.oauthTokenData = newToken.data;
|
|
// Find the credentials
|
|
if (!node.credentials?.[credentialsType]) {
|
|
throw new ApplicationError('Node does not have credential type', {
|
|
extra: { nodeName: node.name, credentialType: credentialsType },
|
|
});
|
|
}
|
|
const nodeCredentials = node.credentials[credentialsType];
|
|
await additionalData.credentialsHelper.updateCredentials(
|
|
nodeCredentials,
|
|
credentialsType,
|
|
credentials as unknown as ICredentialDataDecryptedObject,
|
|
);
|
|
const refreshedRequestOption = newToken.sign(requestOptions as ClientOAuth2RequestObject);
|
|
|
|
if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
|
|
Object.assign(newRequestHeaders, {
|
|
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
|
|
});
|
|
}
|
|
|
|
return await this.helpers.httpRequest(refreshedRequestOption);
|
|
}
|
|
throw error;
|
|
});
|
|
}
|
|
const tokenExpiredStatusCode =
|
|
oAuth2Options?.tokenExpiredStatusCode === undefined
|
|
? 401
|
|
: oAuth2Options?.tokenExpiredStatusCode;
|
|
|
|
return await this.helpers
|
|
.request(newRequestOptions as IRequestOptions)
|
|
.then((response) => {
|
|
const requestOptions = newRequestOptions as any;
|
|
if (
|
|
requestOptions.resolveWithFullResponse === true &&
|
|
requestOptions.simple === false &&
|
|
response.statusCode === tokenExpiredStatusCode
|
|
) {
|
|
throw response;
|
|
}
|
|
return response;
|
|
})
|
|
.catch(async (error: IResponseError) => {
|
|
if (error.statusCode === tokenExpiredStatusCode) {
|
|
// Token is probably not valid anymore. So try refresh it.
|
|
const tokenRefreshOptions: IDataObject = {};
|
|
if (oAuth2Options?.includeCredentialsOnRefreshOnBody) {
|
|
const body: IDataObject = {
|
|
client_id: credentials.clientId,
|
|
client_secret: credentials.clientSecret,
|
|
};
|
|
tokenRefreshOptions.body = body;
|
|
// Override authorization property so the credentials are not included in it
|
|
tokenRefreshOptions.headers = {
|
|
Authorization: '',
|
|
};
|
|
}
|
|
this.logger.debug(
|
|
`OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`,
|
|
);
|
|
|
|
let newToken;
|
|
|
|
// if it's OAuth2 with client credentials grant type, get a new token
|
|
// instead of refreshing it.
|
|
if (credentials.grantType === 'clientCredentials') {
|
|
newToken = await token.client.credentials.getToken();
|
|
} else {
|
|
newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options);
|
|
}
|
|
this.logger.debug(
|
|
`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`,
|
|
);
|
|
|
|
credentials.oauthTokenData = newToken.data;
|
|
|
|
// Find the credentials
|
|
if (!node.credentials?.[credentialsType]) {
|
|
throw new ApplicationError('Node does not have credential type', {
|
|
tags: { credentialType: credentialsType },
|
|
extra: { nodeName: node.name },
|
|
});
|
|
}
|
|
const nodeCredentials = node.credentials[credentialsType];
|
|
|
|
// Save the refreshed token
|
|
await additionalData.credentialsHelper.updateCredentials(
|
|
nodeCredentials,
|
|
credentialsType,
|
|
credentials as unknown as ICredentialDataDecryptedObject,
|
|
);
|
|
|
|
this.logger.debug(
|
|
`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been saved to database successfully.`,
|
|
);
|
|
|
|
// Make the request again with the new token
|
|
const newRequestOptions = newToken.sign(requestOptions as ClientOAuth2RequestObject);
|
|
newRequestOptions.headers = newRequestOptions.headers ?? {};
|
|
|
|
if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
|
|
Object.assign(newRequestOptions.headers, {
|
|
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
|
|
});
|
|
}
|
|
|
|
return await this.helpers.request(newRequestOptions as IRequestOptions);
|
|
}
|
|
|
|
// Unknown error so simply throw it
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Makes a request using OAuth1 data for authentication
|
|
*/
|
|
export async function requestOAuth1(
|
|
this: IAllExecuteFunctions,
|
|
credentialsType: string,
|
|
requestOptions: IHttpRequestOptions | IRequestOptions,
|
|
isN8nRequest = false,
|
|
) {
|
|
removeEmptyBody(requestOptions);
|
|
|
|
const credentials = await this.getCredentials(credentialsType);
|
|
|
|
if (credentials === undefined) {
|
|
throw new ApplicationError('No credentials were returned');
|
|
}
|
|
|
|
if (credentials.oauthTokenData === undefined) {
|
|
throw new ApplicationError('OAuth credentials not connected');
|
|
}
|
|
|
|
const oauth = new clientOAuth1({
|
|
consumer: {
|
|
key: credentials.consumerKey as string,
|
|
secret: credentials.consumerSecret as string,
|
|
},
|
|
signature_method: credentials.signatureMethod as string,
|
|
hash_function(base, key) {
|
|
let algorithm: string;
|
|
switch (credentials.signatureMethod) {
|
|
case 'HMAC-SHA256':
|
|
algorithm = 'sha256';
|
|
break;
|
|
case 'HMAC-SHA512':
|
|
algorithm = 'sha512';
|
|
break;
|
|
default:
|
|
algorithm = 'sha1';
|
|
break;
|
|
}
|
|
return createHmac(algorithm, key).update(base).digest('base64');
|
|
},
|
|
});
|
|
|
|
const oauthTokenData = credentials.oauthTokenData as IDataObject;
|
|
|
|
const token: Token = {
|
|
key: oauthTokenData.oauth_token as string,
|
|
secret: oauthTokenData.oauth_token_secret as string,
|
|
};
|
|
|
|
// @ts-expect-error @TECH_DEBT: Remove request library
|
|
requestOptions.data = { ...requestOptions.qs, ...requestOptions.form };
|
|
|
|
// Fixes issue that OAuth1 library only works with "url" property and not with "uri"
|
|
if ('uri' in requestOptions && !requestOptions.url) {
|
|
requestOptions.url = requestOptions.uri;
|
|
delete requestOptions.uri;
|
|
}
|
|
|
|
requestOptions.headers = oauth.toHeader(
|
|
oauth.authorize(requestOptions as unknown as clientOAuth1.RequestOptions, token),
|
|
) as unknown as Record<string, string>;
|
|
if (isN8nRequest) {
|
|
return await this.helpers.httpRequest(requestOptions as IHttpRequestOptions);
|
|
}
|
|
|
|
return await this.helpers
|
|
.request(requestOptions as IRequestOptions)
|
|
.catch(async (error: IResponseError) => {
|
|
// Unknown error so simply throw it
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
export async function httpRequestWithAuthentication(
|
|
this: IAllExecuteFunctions,
|
|
credentialsType: string,
|
|
requestOptions: IHttpRequestOptions,
|
|
workflow: Workflow,
|
|
node: INode,
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
|
additionalCredentialOptions?: IAdditionalCredentialOptions,
|
|
) {
|
|
removeEmptyBody(requestOptions);
|
|
|
|
// Cancel this request on execution cancellation
|
|
if ('getExecutionCancelSignal' in this) {
|
|
requestOptions.abortSignal = this.getExecutionCancelSignal();
|
|
}
|
|
|
|
let credentialsDecrypted: ICredentialDataDecryptedObject | undefined;
|
|
try {
|
|
const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType);
|
|
|
|
if (parentTypes.includes('oAuth1Api')) {
|
|
return await requestOAuth1.call(this, credentialsType, requestOptions, true);
|
|
}
|
|
if (parentTypes.includes('oAuth2Api')) {
|
|
return await requestOAuth2.call(
|
|
this,
|
|
credentialsType,
|
|
requestOptions,
|
|
node,
|
|
additionalData,
|
|
additionalCredentialOptions?.oauth2,
|
|
true,
|
|
);
|
|
}
|
|
|
|
if (additionalCredentialOptions?.credentialsDecrypted) {
|
|
credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data;
|
|
} else {
|
|
credentialsDecrypted =
|
|
await this.getCredentials<ICredentialDataDecryptedObject>(credentialsType);
|
|
}
|
|
|
|
if (credentialsDecrypted === undefined) {
|
|
throw new NodeOperationError(
|
|
node,
|
|
`Node "${node.name}" does not have any credentials of type "${credentialsType}" set`,
|
|
{ level: 'warning' },
|
|
);
|
|
}
|
|
|
|
const data = await additionalData.credentialsHelper.preAuthentication(
|
|
{ helpers: this.helpers },
|
|
credentialsDecrypted,
|
|
credentialsType,
|
|
node,
|
|
false,
|
|
);
|
|
|
|
if (data) {
|
|
// make the updated property in the credentials
|
|
// available to the authenticate method
|
|
Object.assign(credentialsDecrypted, data);
|
|
}
|
|
|
|
requestOptions = await additionalData.credentialsHelper.authenticate(
|
|
credentialsDecrypted,
|
|
credentialsType,
|
|
requestOptions,
|
|
workflow,
|
|
node,
|
|
);
|
|
return await httpRequest(requestOptions);
|
|
} catch (error) {
|
|
// if there is a pre authorization method defined and
|
|
// the method failed due to unauthorized request
|
|
if (
|
|
error.response?.status === 401 &&
|
|
additionalData.credentialsHelper.preAuthentication !== undefined
|
|
) {
|
|
try {
|
|
if (credentialsDecrypted !== undefined) {
|
|
// try to refresh the credentials
|
|
const data = await additionalData.credentialsHelper.preAuthentication(
|
|
{ helpers: this.helpers },
|
|
credentialsDecrypted,
|
|
credentialsType,
|
|
node,
|
|
true,
|
|
);
|
|
|
|
if (data) {
|
|
// make the updated property in the credentials
|
|
// available to the authenticate method
|
|
Object.assign(credentialsDecrypted, data);
|
|
}
|
|
|
|
requestOptions = await additionalData.credentialsHelper.authenticate(
|
|
credentialsDecrypted,
|
|
credentialsType,
|
|
requestOptions,
|
|
workflow,
|
|
node,
|
|
);
|
|
}
|
|
// retry the request
|
|
return await httpRequest(requestOptions);
|
|
} catch (error) {
|
|
throw new NodeApiError(this.getNode(), error);
|
|
}
|
|
}
|
|
|
|
throw new NodeApiError(this.getNode(), error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Takes generic input data and brings it into the json format n8n uses.
|
|
*
|
|
* @param {(IDataObject | IDataObject[])} jsonData
|
|
*/
|
|
export function returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[] {
|
|
const returnData: INodeExecutionData[] = [];
|
|
|
|
if (!Array.isArray(jsonData)) {
|
|
jsonData = [jsonData];
|
|
}
|
|
|
|
jsonData.forEach((data: IDataObject & { json?: IDataObject }) => {
|
|
if (data?.json) {
|
|
// We already have the JSON key so avoid double wrapping
|
|
returnData.push({ ...data, json: data.json });
|
|
} else {
|
|
returnData.push({ json: data });
|
|
}
|
|
});
|
|
|
|
return returnData;
|
|
}
|
|
|
|
/**
|
|
* Takes generic input data and brings it into the new json, pairedItem format n8n uses.
|
|
* @param {(IPairedItemData)} itemData
|
|
* @param {(INodeExecutionData[])} inputData
|
|
*/
|
|
export function constructExecutionMetaData(
|
|
inputData: INodeExecutionData[],
|
|
options: { itemData: IPairedItemData | IPairedItemData[] },
|
|
): NodeExecutionWithMetadata[] {
|
|
const { itemData } = options;
|
|
return inputData.map((data: INodeExecutionData) => {
|
|
const { json, ...rest } = data;
|
|
return { json, pairedItem: itemData, ...rest } as NodeExecutionWithMetadata;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Automatically put the objects under a 'json' key and don't error,
|
|
* if some objects contain json/binary keys and others don't, throws error 'Inconsistent item format'
|
|
*
|
|
* @param {INodeExecutionData | INodeExecutionData[]} executionData
|
|
*/
|
|
export function normalizeItems(
|
|
executionData: INodeExecutionData | INodeExecutionData[],
|
|
): INodeExecutionData[] {
|
|
if (typeof executionData === 'object' && !Array.isArray(executionData)) {
|
|
executionData = executionData.json ? [executionData] : [{ json: executionData as IDataObject }];
|
|
}
|
|
|
|
if (executionData.every((item) => typeof item === 'object' && 'json' in item))
|
|
return executionData;
|
|
|
|
if (executionData.some((item) => typeof item === 'object' && 'json' in item)) {
|
|
throw new ApplicationError('Inconsistent item format');
|
|
}
|
|
|
|
if (executionData.every((item) => typeof item === 'object' && 'binary' in item)) {
|
|
const normalizedItems: INodeExecutionData[] = [];
|
|
executionData.forEach((item) => {
|
|
const json = Object.keys(item).reduce((acc, key) => {
|
|
if (key === 'binary') return acc;
|
|
return { ...acc, [key]: item[key] };
|
|
}, {});
|
|
|
|
normalizedItems.push({
|
|
json,
|
|
binary: item.binary,
|
|
});
|
|
});
|
|
return normalizedItems;
|
|
}
|
|
|
|
if (executionData.some((item) => typeof item === 'object' && 'binary' in item)) {
|
|
throw new ApplicationError('Inconsistent item format');
|
|
}
|
|
|
|
return executionData.map((item) => {
|
|
return { json: item };
|
|
});
|
|
}
|
|
|
|
// TODO: Move up later
|
|
export async function requestWithAuthentication(
|
|
this: IAllExecuteFunctions,
|
|
credentialsType: string,
|
|
requestOptions: IRequestOptions,
|
|
workflow: Workflow,
|
|
node: INode,
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
|
additionalCredentialOptions?: IAdditionalCredentialOptions,
|
|
itemIndex?: number,
|
|
) {
|
|
removeEmptyBody(requestOptions);
|
|
|
|
let credentialsDecrypted: ICredentialDataDecryptedObject | undefined;
|
|
|
|
try {
|
|
const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType);
|
|
|
|
if (credentialsType === 'oAuth1Api' || parentTypes.includes('oAuth1Api')) {
|
|
return await requestOAuth1.call(this, credentialsType, requestOptions, false);
|
|
}
|
|
if (credentialsType === 'oAuth2Api' || parentTypes.includes('oAuth2Api')) {
|
|
return await requestOAuth2.call(
|
|
this,
|
|
credentialsType,
|
|
requestOptions,
|
|
node,
|
|
additionalData,
|
|
additionalCredentialOptions?.oauth2,
|
|
false,
|
|
);
|
|
}
|
|
|
|
if (additionalCredentialOptions?.credentialsDecrypted) {
|
|
credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data;
|
|
} else {
|
|
credentialsDecrypted = await this.getCredentials<ICredentialDataDecryptedObject>(
|
|
credentialsType,
|
|
itemIndex,
|
|
);
|
|
}
|
|
|
|
if (credentialsDecrypted === undefined) {
|
|
throw new NodeOperationError(
|
|
node,
|
|
`Node "${node.name}" does not have any credentials of type "${credentialsType}" set`,
|
|
{ level: 'warning' },
|
|
);
|
|
}
|
|
|
|
const data = await additionalData.credentialsHelper.preAuthentication(
|
|
{ helpers: this.helpers },
|
|
credentialsDecrypted,
|
|
credentialsType,
|
|
node,
|
|
false,
|
|
);
|
|
|
|
if (data) {
|
|
// make the updated property in the credentials
|
|
// available to the authenticate method
|
|
Object.assign(credentialsDecrypted, data);
|
|
}
|
|
|
|
requestOptions = (await additionalData.credentialsHelper.authenticate(
|
|
credentialsDecrypted,
|
|
credentialsType,
|
|
requestOptions as IHttpRequestOptions,
|
|
workflow,
|
|
node,
|
|
)) as IRequestOptions;
|
|
return await proxyRequestToAxios(workflow, additionalData, node, requestOptions);
|
|
} catch (error) {
|
|
try {
|
|
if (credentialsDecrypted !== undefined) {
|
|
// try to refresh the credentials
|
|
const data = await additionalData.credentialsHelper.preAuthentication(
|
|
{ helpers: this.helpers },
|
|
credentialsDecrypted,
|
|
credentialsType,
|
|
node,
|
|
true,
|
|
);
|
|
|
|
if (data) {
|
|
// make the updated property in the credentials
|
|
// available to the authenticate method
|
|
Object.assign(credentialsDecrypted, data);
|
|
requestOptions = (await additionalData.credentialsHelper.authenticate(
|
|
credentialsDecrypted,
|
|
credentialsType,
|
|
requestOptions as IHttpRequestOptions,
|
|
workflow,
|
|
node,
|
|
)) as IRequestOptions;
|
|
// retry the request
|
|
return await proxyRequestToAxios(workflow, additionalData, node, requestOptions);
|
|
}
|
|
}
|
|
throw error;
|
|
} catch (error) {
|
|
if (error instanceof ExecutionBaseError) throw error;
|
|
|
|
throw new NodeApiError(this.getNode(), error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the webhook URL of the webhook with the given name
|
|
*/
|
|
export function getNodeWebhookUrl(
|
|
name: WebhookType,
|
|
workflow: Workflow,
|
|
node: INode,
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
|
mode: WorkflowExecuteMode,
|
|
additionalKeys: IWorkflowDataProxyAdditionalKeys,
|
|
isTest?: boolean,
|
|
): string | undefined {
|
|
let baseUrl = additionalData.webhookBaseUrl;
|
|
if (isTest === true) {
|
|
baseUrl = additionalData.webhookTestBaseUrl;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
const webhookDescription = getWebhookDescription(name, workflow, node);
|
|
if (webhookDescription === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
const path = workflow.expression.getSimpleParameterValue(
|
|
node,
|
|
webhookDescription.path,
|
|
mode,
|
|
additionalKeys,
|
|
);
|
|
if (path === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
const isFullPath: boolean = workflow.expression.getSimpleParameterValue(
|
|
node,
|
|
webhookDescription.isFullPath,
|
|
mode,
|
|
additionalKeys,
|
|
undefined,
|
|
false,
|
|
) as boolean;
|
|
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id, node, path.toString(), isFullPath);
|
|
}
|
|
|
|
/**
|
|
* Returns the full webhook description of the webhook with the given name
|
|
*/
|
|
export function getWebhookDescription(
|
|
name: WebhookType,
|
|
workflow: Workflow,
|
|
node: INode,
|
|
): IWebhookDescription | undefined {
|
|
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
|
|
|
if (nodeType.description.webhooks === undefined) {
|
|
// Node does not have any webhooks so return
|
|
return undefined;
|
|
}
|
|
|
|
for (const webhookDescription of nodeType.description.webhooks) {
|
|
if (webhookDescription.name === name) {
|
|
return webhookDescription;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export const getRequestHelperFunctions = (
|
|
workflow: Workflow,
|
|
node: INode,
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
|
runExecutionData: IRunExecutionData | null = null,
|
|
connectionInputData: INodeExecutionData[] = [],
|
|
): RequestHelperFunctions => {
|
|
const getResolvedValue = (
|
|
parameterValue: NodeParameterValueType,
|
|
itemIndex: number,
|
|
runIndex: number,
|
|
executeData: IExecuteData,
|
|
additionalKeys?: IWorkflowDataProxyAdditionalKeys,
|
|
returnObjectAsString = false,
|
|
): NodeParameterValueType => {
|
|
const mode: WorkflowExecuteMode = 'internal';
|
|
|
|
if (
|
|
typeof parameterValue === 'object' ||
|
|
(typeof parameterValue === 'string' && parameterValue.charAt(0) === '=')
|
|
) {
|
|
return workflow.expression.getParameterValue(
|
|
parameterValue,
|
|
runExecutionData,
|
|
runIndex,
|
|
itemIndex,
|
|
node.name,
|
|
connectionInputData,
|
|
mode,
|
|
additionalKeys ?? {},
|
|
executeData,
|
|
returnObjectAsString,
|
|
);
|
|
}
|
|
|
|
return parameterValue;
|
|
};
|
|
|
|
return {
|
|
httpRequest,
|
|
// eslint-disable-next-line complexity
|
|
async requestWithAuthenticationPaginated(
|
|
this: IExecuteFunctions,
|
|
requestOptions: IRequestOptions,
|
|
itemIndex: number,
|
|
paginationOptions: PaginationOptions,
|
|
credentialsType?: string,
|
|
additionalCredentialOptions?: IAdditionalCredentialOptions,
|
|
): Promise<any[]> {
|
|
const responseData = [];
|
|
if (!requestOptions.qs) {
|
|
requestOptions.qs = {};
|
|
}
|
|
requestOptions.resolveWithFullResponse = true;
|
|
requestOptions.simple = false;
|
|
|
|
let tempResponseData: IN8nHttpFullResponse;
|
|
let makeAdditionalRequest: boolean;
|
|
let paginateRequestData: PaginationOptions['request'];
|
|
|
|
const runIndex = 0;
|
|
|
|
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
|
|
$request: requestOptions,
|
|
$response: {} as IN8nHttpFullResponse,
|
|
$version: node.typeVersion,
|
|
$pageCount: 0,
|
|
};
|
|
|
|
const executeData: IExecuteData = {
|
|
data: {},
|
|
node,
|
|
source: null,
|
|
};
|
|
|
|
const hashData = {
|
|
identicalCount: 0,
|
|
previousLength: 0,
|
|
previousHash: '',
|
|
};
|
|
do {
|
|
paginateRequestData = getResolvedValue(
|
|
paginationOptions.request as unknown as NodeParameterValueType,
|
|
itemIndex,
|
|
runIndex,
|
|
executeData,
|
|
additionalKeys,
|
|
false,
|
|
) as object as PaginationOptions['request'];
|
|
|
|
const tempRequestOptions = applyPaginationRequestData(requestOptions, paginateRequestData);
|
|
|
|
if (!validateUrl(tempRequestOptions.uri as string)) {
|
|
throw new NodeOperationError(node, `'${paginateRequestData.url}' is not a valid URL.`, {
|
|
itemIndex,
|
|
runIndex,
|
|
type: 'invalid_url',
|
|
});
|
|
}
|
|
|
|
if (credentialsType) {
|
|
tempResponseData = await this.helpers.requestWithAuthentication.call(
|
|
this,
|
|
credentialsType,
|
|
tempRequestOptions,
|
|
additionalCredentialOptions,
|
|
);
|
|
} else {
|
|
tempResponseData = await this.helpers.request(tempRequestOptions);
|
|
}
|
|
|
|
const newResponse: IN8nHttpFullResponse = Object.assign(
|
|
{
|
|
body: {},
|
|
headers: {},
|
|
statusCode: 0,
|
|
},
|
|
pick(tempResponseData, ['body', 'headers', 'statusCode']),
|
|
);
|
|
|
|
let contentBody: Exclude<IN8nHttpResponse, Buffer>;
|
|
|
|
if (newResponse.body instanceof Readable && paginationOptions.binaryResult !== true) {
|
|
// Keep the original string version that we can use it to hash if needed
|
|
contentBody = await binaryToString(newResponse.body as Buffer | Readable);
|
|
|
|
const responseContentType = newResponse.headers['content-type']?.toString() ?? '';
|
|
if (responseContentType.includes('application/json')) {
|
|
newResponse.body = jsonParse(contentBody, { fallbackValue: {} });
|
|
} else {
|
|
newResponse.body = contentBody;
|
|
}
|
|
tempResponseData.__bodyResolved = true;
|
|
tempResponseData.body = newResponse.body;
|
|
} else {
|
|
contentBody = newResponse.body;
|
|
}
|
|
|
|
if (paginationOptions.binaryResult !== true || tempResponseData.headers.etag) {
|
|
// If the data is not binary (and so not a stream), or an etag is present,
|
|
// we check via etag or hash if identical data is received
|
|
|
|
let contentLength = 0;
|
|
if ('content-length' in tempResponseData.headers) {
|
|
contentLength = parseInt(tempResponseData.headers['content-length'] as string) || 0;
|
|
}
|
|
|
|
if (hashData.previousLength === contentLength) {
|
|
let hash: string;
|
|
if (tempResponseData.headers.etag) {
|
|
// If an etag is provided, we use it as "hash"
|
|
hash = tempResponseData.headers.etag as string;
|
|
} else {
|
|
// If there is no etag, we calculate a hash from the data in the body
|
|
if (typeof contentBody !== 'string') {
|
|
contentBody = JSON.stringify(contentBody);
|
|
}
|
|
hash = crypto.createHash('md5').update(contentBody).digest('base64');
|
|
}
|
|
|
|
if (hashData.previousHash === hash) {
|
|
hashData.identicalCount += 1;
|
|
if (hashData.identicalCount > 2) {
|
|
// Length was identical 5x and hash 3x
|
|
throw new NodeOperationError(
|
|
node,
|
|
'The returned response was identical 5x, so requests got stopped',
|
|
{
|
|
itemIndex,
|
|
description:
|
|
'Check if "Pagination Completed When" has been configured correctly.',
|
|
},
|
|
);
|
|
}
|
|
} else {
|
|
hashData.identicalCount = 0;
|
|
}
|
|
hashData.previousHash = hash;
|
|
} else {
|
|
hashData.identicalCount = 0;
|
|
}
|
|
hashData.previousLength = contentLength;
|
|
}
|
|
|
|
responseData.push(tempResponseData);
|
|
|
|
additionalKeys.$response = newResponse;
|
|
additionalKeys.$pageCount = (additionalKeys.$pageCount ?? 0) + 1;
|
|
|
|
const maxRequests = getResolvedValue(
|
|
paginationOptions.maxRequests,
|
|
itemIndex,
|
|
runIndex,
|
|
executeData,
|
|
additionalKeys,
|
|
false,
|
|
) as number;
|
|
|
|
if (maxRequests && additionalKeys.$pageCount >= maxRequests) {
|
|
break;
|
|
}
|
|
|
|
makeAdditionalRequest = getResolvedValue(
|
|
paginationOptions.continue,
|
|
itemIndex,
|
|
runIndex,
|
|
executeData,
|
|
additionalKeys,
|
|
false,
|
|
) as boolean;
|
|
|
|
if (makeAdditionalRequest) {
|
|
if (paginationOptions.requestInterval) {
|
|
const requestInterval = getResolvedValue(
|
|
paginationOptions.requestInterval,
|
|
itemIndex,
|
|
runIndex,
|
|
executeData,
|
|
additionalKeys,
|
|
false,
|
|
) as number;
|
|
|
|
await sleep(requestInterval);
|
|
}
|
|
if (tempResponseData.statusCode < 200 || tempResponseData.statusCode >= 300) {
|
|
// We have it configured to let all requests pass no matter the response code
|
|
// via "requestOptions.simple = false" to not by default fail if it is for example
|
|
// configured to stop on 404 response codes. For that reason we have to throw here
|
|
// now an error manually if the response code is not a success one.
|
|
let data = tempResponseData.body;
|
|
if (data instanceof Readable && paginationOptions.binaryResult !== true) {
|
|
data = await binaryToString(data as Buffer | Readable);
|
|
} else if (typeof data === 'object') {
|
|
data = JSON.stringify(data);
|
|
}
|
|
|
|
throw Object.assign(
|
|
new Error(`${tempResponseData.statusCode} - "${data?.toString()}"`),
|
|
{
|
|
statusCode: tempResponseData.statusCode,
|
|
error: data,
|
|
isAxiosError: true,
|
|
response: {
|
|
headers: tempResponseData.headers,
|
|
status: tempResponseData.statusCode,
|
|
statusText: tempResponseData.statusMessage,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}
|
|
} while (makeAdditionalRequest);
|
|
|
|
return responseData;
|
|
},
|
|
async httpRequestWithAuthentication(
|
|
this,
|
|
credentialsType,
|
|
requestOptions,
|
|
additionalCredentialOptions,
|
|
): Promise<any> {
|
|
return await httpRequestWithAuthentication.call(
|
|
this,
|
|
credentialsType,
|
|
requestOptions,
|
|
workflow,
|
|
node,
|
|
additionalData,
|
|
additionalCredentialOptions,
|
|
);
|
|
},
|
|
|
|
request: async (uriOrObject, options) =>
|
|
await proxyRequestToAxios(workflow, additionalData, node, uriOrObject, options),
|
|
|
|
async requestWithAuthentication(
|
|
this,
|
|
credentialsType,
|
|
requestOptions,
|
|
additionalCredentialOptions,
|
|
itemIndex,
|
|
): Promise<any> {
|
|
return await requestWithAuthentication.call(
|
|
this,
|
|
credentialsType,
|
|
requestOptions,
|
|
workflow,
|
|
node,
|
|
additionalData,
|
|
additionalCredentialOptions,
|
|
itemIndex,
|
|
);
|
|
},
|
|
|
|
async requestOAuth1(
|
|
this: IAllExecuteFunctions,
|
|
credentialsType: string,
|
|
requestOptions: IRequestOptions,
|
|
): Promise<any> {
|
|
return await requestOAuth1.call(this, credentialsType, requestOptions);
|
|
},
|
|
|
|
async requestOAuth2(
|
|
this: IAllExecuteFunctions,
|
|
credentialsType: string,
|
|
requestOptions: IRequestOptions,
|
|
oAuth2Options?: IOAuth2Options,
|
|
): Promise<any> {
|
|
return await requestOAuth2.call(
|
|
this,
|
|
credentialsType,
|
|
requestOptions,
|
|
node,
|
|
additionalData,
|
|
oAuth2Options,
|
|
);
|
|
},
|
|
};
|
|
};
|
|
|
|
export const getSSHTunnelFunctions = (): SSHTunnelFunctions => ({
|
|
getSSHClient: async (credentials) =>
|
|
await Container.get(SSHClientsManager).getClient(credentials),
|
|
});
|
|
|
|
export const getSchedulingFunctions = (workflow: Workflow): SchedulingFunctions => {
|
|
const scheduledTaskManager = Container.get(ScheduledTaskManager);
|
|
return {
|
|
registerCron: (cronExpression, onTick) =>
|
|
scheduledTaskManager.registerCron(workflow, cronExpression, onTick),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Returns a copy of the items which only contains the json data and
|
|
* of that only the defined properties
|
|
*/
|
|
export function copyInputItems(items: INodeExecutionData[], properties: string[]): IDataObject[] {
|
|
return items.map((item) => {
|
|
const newItem: IDataObject = {};
|
|
for (const property of properties) {
|
|
if (item.json[property] === undefined) {
|
|
newItem[property] = null;
|
|
} else {
|
|
newItem[property] = deepCopy(item.json[property]);
|
|
}
|
|
}
|
|
return newItem;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the execute functions the poll nodes have access to.
|
|
*/
|
|
// TODO: Check if I can get rid of: additionalData, and so then maybe also at ActiveWorkflowManager.add
|
|
export function getExecutePollFunctions(
|
|
workflow: Workflow,
|
|
node: INode,
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
|
mode: WorkflowExecuteMode,
|
|
activation: WorkflowActivateMode,
|
|
): IPollFunctions {
|
|
return new PollContext(workflow, node, additionalData, mode, activation);
|
|
}
|
|
|
|
/**
|
|
* Returns the execute functions the trigger nodes have access to.
|
|
*/
|
|
// TODO: Check if I can get rid of: additionalData, and so then maybe also at ActiveWorkflowManager.add
|
|
export function getExecuteTriggerFunctions(
|
|
workflow: Workflow,
|
|
node: INode,
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
|
mode: WorkflowExecuteMode,
|
|
activation: WorkflowActivateMode,
|
|
): ITriggerFunctions {
|
|
return new TriggerContext(workflow, node, additionalData, mode, activation);
|
|
}
|