mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-19 19:11:13 +00:00
feat(HTTP Request Node): Redesign and add the ability to import cURL commands (#3860)
* ⚡ Initial commit * 👕 Fix linting issue * ⚡ Add import button * ⚡ Remove ligh versioning * ⚡ Improvements * ⚡ Improvements * 🔥 Remove HttpRequest2 file used for testing * 🐛 Fix building issue * ⚡ Small improvement * 👕 Fix linting issue * 🔥 Remove HttpRequest2 from loader * ⚡ Update package-lock.json * ⚡ Improvements * ⚡ Small change * 🐛 Fix issue retrieving splitIntoItems * 🐛 Fix issue retrieving neverError parameter * 🐛 Fix issue with displayOptions * ⚡ Improvements * ⚡ Improvements * ⚡ Improvements * ⚡ Improvements * ⚡ Move cURL section to its own component * ⚡ Improvements * ⚡ Improvements * ⚡ Add fix for batching in all versions * ⚡ Add notice to cURL modal * 🔥 Remove comments * ⚡ Improvements * ⚡ Type curl-to-json endpoint * ⚡ Fix typo * 🔥 Remove console.logs * ⚡ Fix typo in curl-to-json endpoint * ⚡ Improvements * ⚡ Improvements * ⚡ Update package-lock.json * ⚡ Rename import modal constant * ⚡ Add return types to methods * ⚡ Add CSS modules to ImportParameter component * ⚡ Rename ImportParameter component to use kebab-case * ⚡ Improvements * ⚡ update package-lock.json * ⚡ Fix linting issues * Fix issue with css reference in ImportParameter component * ⚡ Small improvements * ⚡ Rename redirects to redirect * ⚡ Allow to set multiple parameters on valueChanged * 👕 Fix linting issue * 🐛 Add mistakenly removed openExistingCredentials * ⚡ Improve curl regex * ⚡ Keep headers as defined in the cURL command * ⚡ Account for all protocols supported by cURL * ⚡ Add tests * 🔥 Remove unnecessary lines * ⚡ Add more testing * ⚡ Add noDataExpression to dependent fields * 🐛 Fix bug not handling multipart-form data correctly * ⚡ Change error messages * 🐛 Fix response format string for empty values * Fix typo
This commit is contained in:
466
packages/cli/src/CurlConverterHelper.ts
Normal file
466
packages/cli/src/CurlConverterHelper.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import curlconverter from 'curlconverter';
|
||||
import get from 'lodash.get';
|
||||
|
||||
interface CurlJson {
|
||||
url: string;
|
||||
raw_url?: string;
|
||||
method: string;
|
||||
contentType?: string;
|
||||
cookies?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
auth?: {
|
||||
user: string;
|
||||
password: string;
|
||||
};
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
files?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
queries: {
|
||||
[key: string]: string;
|
||||
};
|
||||
data?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Parameter {
|
||||
parameterType?: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HttpNodeParameters {
|
||||
url?: string;
|
||||
method: string;
|
||||
sendBody?: boolean;
|
||||
authentication: string;
|
||||
contentType?: 'form-urlencoded' | 'multipart-form-data' | 'json' | 'raw' | 'binaryData';
|
||||
rawContentType?: string;
|
||||
specifyBody?: 'json' | 'keypair';
|
||||
bodyParameters?: {
|
||||
parameters: Parameter[];
|
||||
};
|
||||
jsonBody?: object;
|
||||
options: {
|
||||
allowUnauthorizedCerts?: boolean;
|
||||
proxy?: string;
|
||||
timeout?: number;
|
||||
redirect: {
|
||||
redirect: {
|
||||
followRedirects?: boolean;
|
||||
maxRedirects?: number;
|
||||
};
|
||||
};
|
||||
response: {
|
||||
response: {
|
||||
fullResponse?: boolean;
|
||||
responseFormat?: string;
|
||||
outputPropertyName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
sendHeaders?: boolean;
|
||||
headerParameters?: {
|
||||
parameters: Parameter[];
|
||||
};
|
||||
sendQuery?: boolean;
|
||||
queryParameters?: {
|
||||
parameters: Parameter[];
|
||||
};
|
||||
}
|
||||
|
||||
type HttpNodeHeaders = Pick<HttpNodeParameters, 'sendHeaders' | 'headerParameters'>;
|
||||
|
||||
type HttpNodeQueries = Pick<HttpNodeParameters, 'sendQuery' | 'queryParameters'>;
|
||||
|
||||
enum ContentTypes {
|
||||
applicationJson = 'application/json',
|
||||
applicationFormUrlEncoded = 'application/x-www-form-urlencoded',
|
||||
applicationMultipart = 'multipart/form-data',
|
||||
}
|
||||
|
||||
const SUPPORTED_CONTENT_TYPES = [
|
||||
ContentTypes.applicationJson,
|
||||
ContentTypes.applicationFormUrlEncoded,
|
||||
ContentTypes.applicationMultipart,
|
||||
];
|
||||
|
||||
const CONTENT_TYPE_KEY = 'content-type';
|
||||
|
||||
const FOLLOW_REDIRECT_FLAGS = ['--location', '-L'];
|
||||
|
||||
const MAX_REDIRECT_FLAG = '--max-redirs';
|
||||
|
||||
const PROXY_FLAGS = ['-x', '--proxy'];
|
||||
|
||||
const INCLUDE_HEADERS_IN_OUTPUT_FLAGS = ['-i', '--include'];
|
||||
|
||||
const REQUEST_FLAGS = ['-X', '--request'];
|
||||
|
||||
const TIMEOUT_FLAGS = ['--connect-timeout'];
|
||||
|
||||
const DOWNLOAD_FILE_FLAGS = ['-O', '-o'];
|
||||
|
||||
const IGNORE_SSL_ISSUES_FLAGS = ['-k', '--insecure'];
|
||||
|
||||
const curlToJson = (curlCommand: string): CurlJson => {
|
||||
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse
|
||||
return JSON.parse(curlconverter.toJsonString(curlCommand)) as CurlJson;
|
||||
};
|
||||
|
||||
const isContentType = (headers: CurlJson['headers'], contentType: ContentTypes): boolean => {
|
||||
return get(headers, CONTENT_TYPE_KEY) === contentType;
|
||||
};
|
||||
|
||||
const isJsonRequest = (curlJson: CurlJson): boolean => {
|
||||
if (isContentType(curlJson.headers, ContentTypes.applicationJson)) return true;
|
||||
|
||||
if (curlJson.data) {
|
||||
const bodyKey = Object.keys(curlJson.data)[0];
|
||||
try {
|
||||
JSON.parse(bodyKey);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isFormUrlEncodedRequest = (curlJson: CurlJson): boolean => {
|
||||
if (isContentType(curlJson.headers, ContentTypes.applicationFormUrlEncoded)) return true;
|
||||
if (curlJson.data && !curlJson.files) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isMultipartRequest = (curlJson: CurlJson): boolean => {
|
||||
if (isContentType(curlJson.headers, ContentTypes.applicationMultipart)) return true;
|
||||
|
||||
// only multipart/form-data request include files
|
||||
if (curlJson.files) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isBinaryRequest = (curlJson: CurlJson): boolean => {
|
||||
if (curlJson?.headers?.[CONTENT_TYPE_KEY]) {
|
||||
const contentType = curlJson?.headers?.[CONTENT_TYPE_KEY];
|
||||
return ['image', 'video', 'audio'].some((d) => contentType.includes(d));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const sanatizeCurlCommand = (curlCommand: string) =>
|
||||
curlCommand
|
||||
.replace(/\r\n/g, ' ')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/\\/g, ' ')
|
||||
.replace(/[ ]{2,}/g, ' ');
|
||||
|
||||
const toKeyValueArray = ([key, value]: string[]) => ({ name: key, value });
|
||||
|
||||
const extractHeaders = (headers: CurlJson['headers'] = {}): HttpNodeHeaders => {
|
||||
const emptyHeaders = !Object.keys(headers).length;
|
||||
|
||||
const onlyContentTypeHeaderDefined =
|
||||
Object.keys(headers).length === 1 && headers[CONTENT_TYPE_KEY] !== undefined;
|
||||
|
||||
if (emptyHeaders || onlyContentTypeHeaderDefined) return { sendHeaders: false };
|
||||
|
||||
return {
|
||||
sendHeaders: true,
|
||||
headerParameters: {
|
||||
parameters: Object.entries(headers)
|
||||
.map(toKeyValueArray)
|
||||
.filter((parameter) => parameter.name !== CONTENT_TYPE_KEY),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const extractQueries = (queries: CurlJson['queries'] = {}): HttpNodeQueries => {
|
||||
const emptyQueries = !Object.keys(queries).length;
|
||||
|
||||
if (emptyQueries) return { sendQuery: false };
|
||||
|
||||
return {
|
||||
sendQuery: true,
|
||||
queryParameters: {
|
||||
parameters: Object.entries(queries).map(toKeyValueArray),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const extractJson = (body: CurlJson['data']) =>
|
||||
//@ts-ignore
|
||||
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse
|
||||
JSON.parse(Object.keys(body)[0]) as { [key: string]: string };
|
||||
|
||||
const jsonBodyToNodeParameters = (body: CurlJson['data'] = {}): Parameter[] | [] => {
|
||||
const data = extractJson(body);
|
||||
return Object.entries(data).map(toKeyValueArray);
|
||||
};
|
||||
|
||||
const multipartToNodeParameters = (
|
||||
body: CurlJson['data'] = {},
|
||||
files: CurlJson['files'] = {},
|
||||
): Parameter[] | [] => {
|
||||
return [
|
||||
...Object.entries(body)
|
||||
.map(toKeyValueArray)
|
||||
.map((e) => ({ parameterType: 'formData', ...e })),
|
||||
...Object.entries(files)
|
||||
.map(toKeyValueArray)
|
||||
.map((e) => ({ parameterType: 'formBinaryData', ...e })),
|
||||
];
|
||||
};
|
||||
|
||||
const keyValueBodyToNodeParameters = (body: CurlJson['data'] = {}): Parameter[] | [] => {
|
||||
return Object.entries(body).map(toKeyValueArray);
|
||||
};
|
||||
|
||||
const lowerCaseContentTypeKey = (obj: { [x: string]: string }): void => {
|
||||
const regex = new RegExp(CONTENT_TYPE_KEY, 'gi');
|
||||
|
||||
const contentTypeKey = Object.keys(obj).find((key) => {
|
||||
const group = Array.from(key.matchAll(regex));
|
||||
if (group.length) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!contentTypeKey) return;
|
||||
|
||||
const value = obj[contentTypeKey];
|
||||
delete obj[contentTypeKey];
|
||||
obj[CONTENT_TYPE_KEY] = value;
|
||||
};
|
||||
|
||||
const encodeBasicAuthentication = (username: string, password: string) =>
|
||||
Buffer.from(`${username}:${password}`).toString('base64');
|
||||
|
||||
const jsonHasNestedObjects = (json: { [key: string]: string | number | object }) =>
|
||||
Object.values(json).some((e) => typeof e === 'object');
|
||||
|
||||
const extractGroup = (curlCommand: string, regex: RegExp) => curlCommand.matchAll(regex);
|
||||
|
||||
const mapCookies = (cookies: CurlJson['cookies']): { cookie: string } | {} => {
|
||||
if (!cookies) return {};
|
||||
|
||||
const cookiesValues = Object.entries(cookies).reduce(
|
||||
(accumulator: string, entry: [string, string]) => {
|
||||
accumulator += `${entry[0]}=${entry[1]};`;
|
||||
return accumulator;
|
||||
},
|
||||
'',
|
||||
);
|
||||
|
||||
if (!cookiesValues) return {};
|
||||
|
||||
return {
|
||||
cookie: cookiesValues,
|
||||
};
|
||||
};
|
||||
|
||||
export const toHttpNodeParameters = (curlCommand: string): HttpNodeParameters => {
|
||||
const curlJson = curlToJson(curlCommand);
|
||||
|
||||
if (!curlJson.headers) curlJson.headers = {};
|
||||
|
||||
lowerCaseContentTypeKey(curlJson.headers);
|
||||
|
||||
// set basic authentication
|
||||
if (curlJson.auth) {
|
||||
const { user, password: pass } = curlJson.auth;
|
||||
Object.assign(curlJson.headers, {
|
||||
authorization: `Basic ${encodeBasicAuthentication(user, pass)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const httpNodeParameters: HttpNodeParameters = {
|
||||
url: curlJson.url,
|
||||
authentication: 'none',
|
||||
method: curlJson.method.toUpperCase(),
|
||||
...extractHeaders({ ...curlJson.headers, ...mapCookies(curlJson.cookies) }),
|
||||
...extractQueries(curlJson.queries),
|
||||
options: {
|
||||
redirect: {
|
||||
redirect: {},
|
||||
},
|
||||
response: {
|
||||
response: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
//attempt to get the curl flags not supported by the library
|
||||
const curl = sanatizeCurlCommand(curlCommand);
|
||||
|
||||
//check for follow redirect flags
|
||||
if (FOLLOW_REDIRECT_FLAGS.some((flag) => curl.includes(` ${flag}`))) {
|
||||
Object.assign(httpNodeParameters.options.redirect?.redirect, { followRedirects: true });
|
||||
|
||||
if (curl.includes(` ${MAX_REDIRECT_FLAG}`)) {
|
||||
const extractedValue = Array.from(
|
||||
extractGroup(curl, new RegExp(` ${MAX_REDIRECT_FLAG} (\\d+)`, 'g')),
|
||||
);
|
||||
if (extractedValue.length) {
|
||||
const [_, maxRedirects] = extractedValue[0];
|
||||
if (maxRedirects) {
|
||||
Object.assign(httpNodeParameters.options.redirect?.redirect, { maxRedirects });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//check for proxy flags
|
||||
if (PROXY_FLAGS.some((flag) => curl.includes(` ${flag}`))) {
|
||||
const foundFlag = PROXY_FLAGS.find((flag) => curl.includes(` ${flag}`));
|
||||
if (foundFlag) {
|
||||
const extractedValue = Array.from(
|
||||
extractGroup(curl, new RegExp(` ${foundFlag} (\\S*)`, 'g')),
|
||||
);
|
||||
if (extractedValue.length) {
|
||||
const [_, proxy] = extractedValue[0];
|
||||
Object.assign(httpNodeParameters.options, { proxy });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check for "include header in output" flag
|
||||
if (INCLUDE_HEADERS_IN_OUTPUT_FLAGS.some((flag) => curl.includes(` ${flag}`))) {
|
||||
Object.assign(httpNodeParameters.options?.response?.response, {
|
||||
fullResponse: true,
|
||||
responseFormat: 'autodetect',
|
||||
});
|
||||
}
|
||||
|
||||
// check for request flag
|
||||
if (REQUEST_FLAGS.some((flag) => curl.includes(` ${flag}`))) {
|
||||
const foundFlag = REQUEST_FLAGS.find((flag) => curl.includes(` ${flag}`));
|
||||
if (foundFlag) {
|
||||
const extractedValue = Array.from(
|
||||
extractGroup(curl, new RegExp(` ${foundFlag} (\\w+)`, 'g')),
|
||||
);
|
||||
if (extractedValue.length) {
|
||||
const [_, request] = extractedValue[0];
|
||||
httpNodeParameters.method = request.toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check for timeout flag
|
||||
if (TIMEOUT_FLAGS.some((flag) => curl.includes(` ${flag}`))) {
|
||||
const foundFlag = TIMEOUT_FLAGS.find((flag) => curl.includes(` ${flag}`));
|
||||
if (foundFlag) {
|
||||
const extractedValue = Array.from(
|
||||
extractGroup(curl, new RegExp(` ${foundFlag} (\\d+)`, 'g')),
|
||||
);
|
||||
if (extractedValue.length) {
|
||||
const [_, timeout] = extractedValue[0];
|
||||
Object.assign(httpNodeParameters.options, {
|
||||
timeout: parseInt(timeout, 10) * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check for download flag
|
||||
if (DOWNLOAD_FILE_FLAGS.some((flag) => curl.includes(` ${flag}`))) {
|
||||
const foundFlag = DOWNLOAD_FILE_FLAGS.find((flag) => curl.includes(` ${flag}`));
|
||||
if (foundFlag) {
|
||||
Object.assign(httpNodeParameters.options.response.response, {
|
||||
responseFormat: 'file',
|
||||
outputPropertyName: 'data',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (IGNORE_SSL_ISSUES_FLAGS.some((flag) => curl.includes(` ${flag}`))) {
|
||||
const foundFlag = IGNORE_SSL_ISSUES_FLAGS.find((flag) => curl.includes(` ${flag}`));
|
||||
if (foundFlag) {
|
||||
Object.assign(httpNodeParameters.options, {
|
||||
allowUnauthorizedCerts: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = curlJson?.headers?.[CONTENT_TYPE_KEY] as ContentTypes;
|
||||
|
||||
if (isBinaryRequest(curlJson)) {
|
||||
return Object.assign(httpNodeParameters, {
|
||||
contentType: 'binaryData',
|
||||
sendBody: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (contentType && !SUPPORTED_CONTENT_TYPES.includes(contentType)) {
|
||||
return Object.assign(httpNodeParameters, {
|
||||
sendBody: true,
|
||||
contentType: 'raw',
|
||||
rawContentType: contentType,
|
||||
body: Object.keys(curlJson?.data ?? {})[0],
|
||||
});
|
||||
}
|
||||
|
||||
if (isJsonRequest(curlJson)) {
|
||||
Object.assign(httpNodeParameters, {
|
||||
contentType: 'json',
|
||||
sendBody: true,
|
||||
});
|
||||
|
||||
const json = extractJson(curlJson.data);
|
||||
|
||||
if (jsonHasNestedObjects(json)) {
|
||||
// json body
|
||||
Object.assign(httpNodeParameters, {
|
||||
specifyBody: 'json',
|
||||
jsonBody: JSON.stringify(json),
|
||||
});
|
||||
} else {
|
||||
// key-value body
|
||||
Object.assign(httpNodeParameters, {
|
||||
specifyBody: 'keypair',
|
||||
bodyParameters: {
|
||||
parameters: jsonBodyToNodeParameters(curlJson.data),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (isFormUrlEncodedRequest(curlJson)) {
|
||||
Object.assign(httpNodeParameters, {
|
||||
contentType: 'form-urlencoded',
|
||||
sendBody: true,
|
||||
specifyBody: 'keypair',
|
||||
bodyParameters: {
|
||||
parameters: keyValueBodyToNodeParameters(curlJson.data),
|
||||
},
|
||||
});
|
||||
} else if (isMultipartRequest(curlJson)) {
|
||||
Object.assign(httpNodeParameters, {
|
||||
contentType: 'multipart-form-data',
|
||||
sendBody: true,
|
||||
bodyParameters: {
|
||||
parameters: multipartToNodeParameters(curlJson.data, curlJson.files),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// could not figure the content type so do not set the body
|
||||
Object.assign(httpNodeParameters, {
|
||||
sendBody: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.keys(httpNodeParameters.options?.redirect.redirect).length) {
|
||||
// @ts-ignore
|
||||
delete httpNodeParameters.options.redirect;
|
||||
}
|
||||
|
||||
if (!Object.keys(httpNodeParameters.options.response.response).length) {
|
||||
// @ts-ignore
|
||||
delete httpNodeParameters.options.response;
|
||||
}
|
||||
|
||||
return httpNodeParameters;
|
||||
};
|
||||
Reference in New Issue
Block a user