Merge pull request #4959 from maintainer-bruno/feat/curl-parser

fix(import): curl parser library
This commit is contained in:
lohit
2025-06-24 19:45:20 +05:30
committed by GitHub
8 changed files with 1233 additions and 388 deletions

14
package-lock.json generated
View File

@@ -28029,12 +28029,12 @@
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1",
"yup": "^0.32.11"
},
"devDependencies": {
@@ -29667,6 +29667,18 @@
"node": ">=10"
}
},
"packages/bruno-app/node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-app/node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View File

@@ -73,12 +73,12 @@
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1",
"yup": "^0.32.11"
},
"devDependencies": {
@@ -91,9 +91,9 @@
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/dom": "^10.4.0",
"autoprefixer": "10.4.20",
"babel-jest": "^29.7.0",
"babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
@@ -111,4 +111,4 @@
"webpack": "^5.64.4",
"webpack-cli": "^4.9.1"
}
}
}

View File

@@ -1,5 +1,6 @@
import { customAlphabet } from 'nanoid';
import xmlFormat from 'xml-formatter';
import { format, applyEdits } from 'jsonc-parser';
// a customized version of nanoid without using _ and -
export const uuid = () => {
@@ -51,9 +52,12 @@ export const safeStringifyJSON = (obj, indent = false) => {
}
};
export const convertToCodeMirrorJson = (obj) => {
export const prettifyJSON = (obj, spaces = 2) => {
try {
return JSON.stringify(obj, null, 2).slice(1, -1);
const formatted = obj.replace(/\\"/g, '"').replace(/\\'/g, "'");
const edits = format(formatted, undefined, { tabSize: spaces, insertSpaces: true });
return applyEdits(formatted, edits);
} catch (e) {
return obj;
}

View File

@@ -49,15 +49,7 @@ function getDataString(request) {
const contentType = getContentType(request.headers);
if (contentType && contentType.includes('application/json')) {
try {
const parsedData = JSON.parse(request.data);
return { data: JSON.stringify(parsedData) };
} catch (error) {
console.error('Failed to parse JSON data:', error);
return { data: request.data.toString() };
}
} else if (contentType && (contentType.includes('application/xml') || contentType.includes('text/plain'))) {
if (contentType && (contentType.includes('application/json') || contentType.includes('application/xml') || contentType.includes('text/plain'))) {
return { data: request.data };
}
@@ -182,8 +174,12 @@ const curlToJson = (curlCommand) => {
}
if (request.query) {
requestJson.queries = getQueries(request);
} else if (request.multipartUploads) {
const queries = getQueries(request);
// append query to requestJson.url
requestJson.url = requestJson.url + '?' + querystring.stringify(queries);
}
if (request.multipartUploads) {
requestJson.data = request.multipartUploads;
if (!requestJson.headers) {
requestJson.headers = {};

View File

@@ -62,7 +62,7 @@ describe('curlToJson', () => {
it('should accept escaped curl string', () => {
const curlCommand = `curl https://www.usebruno.com
-H $'cookie: val_1=\'\'; val_2=\\^373:0\\^373:0; val_3=\u0068\u0065\u006C\u006C\u006F'
-H $'cookie: val_1=\\'\\'; val_2=\\^373:0\\^373:0; val_3=\u0068\u0065\u006C\u006C\u006F'
`;
const result = curlToJson(curlCommand);

View File

@@ -1,5 +1,5 @@
import { forOwn } from 'lodash';
import { convertToCodeMirrorJson } from 'utils/common';
import { prettifyJSON } from 'utils/common';
import curlToJson from './curl-to-json';
export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => {
@@ -63,7 +63,7 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
body.file = parsedBody;
}else if (contentType.includes('application/json')) {
body.mode = 'json';
body.json = convertToCodeMirrorJson(parsedBody);
body.json = prettifyJSON(parsedBody);
} else if (contentType.includes('xml')) {
body.mode = 'xml';
body.xml = parsedBody;
@@ -77,7 +77,11 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
body.mode = 'text';
body.text = parsedBody;
}
} else if (parsedBody) {
body.mode = 'formUrlEncoded';
body.formUrlEncoded = parseFormData(parsedBody);
}
return {
url: request.url,
method: request.method,

View File

@@ -1,280 +1,499 @@
import cookie from 'cookie';
import URL from 'url';
import querystring from 'query-string';
import { parse } from 'shell-quote';
import { isEmpty } from 'lodash';
/**
* Copyright (c) 2014-2016 Nick Carneiro
* https://github.com/curlconverter/curlconverter
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* Flag definitions - maps flag names to their states and actions
* State-returning flags expect a value, immediate action flags don't
*/
const FLAG_CATEGORIES = {
// State-returning flags (expect a value after the flag)
'user-agent': ['-A', '--user-agent'],
'header': ['-H', '--header'],
'data': ['-d', '--data', '--data-ascii', '--data-urlencode'],
'json': ['--json'],
'user': ['-u', '--user'],
'method': ['-X', '--request'],
'cookie': ['-b', '--cookie'],
'form': ['-F', '--form'],
// Special data flags with properties
'data-raw': ['--data-raw'],
'data-binary': ['--data-binary'],
import * as cookie from 'cookie';
import * as URL from 'url';
import * as querystring from 'query-string';
import yargs from 'yargs-parser';
// Immediate action flags (no value expected)
'head': ['-I', '--head'],
'compressed': ['--compressed'],
'insecure': ['-k', '--insecure'],
/**
* Query flags: mark data for conversion to query parameters.
* While this is an immediate action flag, the actual conversion to a query string occurs later during post-build request processing.
* Due to the unpredictable order of flags, query string construction is deferred to the end.
*/
'query': ['-G', '--get']
};
const parseCurlCommand = (curlCommand) => {
// catch escape sequences (e.g. -H $'cookie: it=\'\'')
curlCommand = curlCommand.replace(/\$('.*')/g, (match, group) => group);
/**
* Parse a curl command into a request object
*
* @TODO
* - Handle T (file upload)
*/
const parseCurlCommand = (curl) => {
const cleanedCommand = cleanCurlCommand(curl);
const parsedArgs = parse(cleanedCommand);
const request = buildRequest(parsedArgs);
// Remove newlines (and from continuations)
curlCommand = curlCommand.replace(/\\\r|\\\n/g, '');
return cleanRequest(postBuildProcessRequest(request));
};
// Remove extra whitespace
curlCommand = curlCommand.replace(/\s+/g, ' ');
/**
* Build request object by processing parsed arguments
* Uses a state machine pattern to handle flag-value pairs
*/
const buildRequest = (parsedArgs) => {
const request = { headers: {} };
let currentState = null;
// yargs parses -XPOST as separate arguments. just prescreen for it.
curlCommand = curlCommand.replace(/ -XPOST/, ' -X POST');
curlCommand = curlCommand.replace(/ -XGET/, ' -X GET');
curlCommand = curlCommand.replace(/ -XPUT/, ' -X PUT');
curlCommand = curlCommand.replace(/ -XPATCH/, ' -X PATCH');
curlCommand = curlCommand.replace(/ -XDELETE/, ' -X DELETE');
curlCommand = curlCommand.replace(/ -XOPTIONS/, ' -X OPTIONS');
// Safari adds `-Xnull` if is unable to determine the request type, it can be ignored
curlCommand = curlCommand.replace(/ -Xnull/, ' ');
curlCommand = curlCommand.trim();
const parsedArguments = yargs(curlCommand, {
boolean: ['I', 'head', 'compressed', 'L', 'k', 'silent', 's', 'G', 'get'],
alias: {
H: 'header',
A: 'user-agent',
u: 'user',
F: 'form'
}
});
let cookieString;
let cookies;
let url = parsedArguments._[1] || '';
// remove surrounding quotes if present
if (url && url.length) {
url = url.replace(/^['"]|['"]$/g, '');
}
// if url argument wasn't where we expected it, try to find it in the other arguments
if (!url) {
for (const argName in parsedArguments) {
if (typeof parsedArguments[argName] === 'string') {
if (parsedArguments[argName].indexOf('http') === 0 || parsedArguments[argName].indexOf('www.') === 0) {
url = parsedArguments[argName];
}
}
for (const arg of parsedArgs) {
const newState = processArgument(arg, currentState, request);
// Reset state after handling a value, or update to new state
if (currentState && !newState) {
currentState = null;
} else if (newState) {
currentState = newState;
}
}
let headers;
if (parsedArguments.header) {
if (!headers) {
headers = {};
}
if (!Array.isArray(parsedArguments.header)) {
parsedArguments.header = [parsedArguments.header];
}
parsedArguments.header.forEach((header) => {
if (header.indexOf('Cookie') !== -1) {
cookieString = header;
}
const components = header.split(/:(.*)/);
if (components[1]) {
headers[components[0]] = components[1].trim();
}
});
}
if (parsedArguments['user-agent']) {
if (!headers) {
headers = {};
}
headers['User-Agent'] = parsedArguments['user-agent'];
}
if (parsedArguments.b) {
cookieString = parsedArguments.b;
}
if (parsedArguments.cookie) {
cookieString = parsedArguments.cookie;
}
let multipartUploads;
// Handle multipart form data specified via -F or --form flags
// Example: curl -F 'id=123' -F 'file=@/path/to/file.txt'
if (parsedArguments.F || parsedArguments.form) {
multipartUploads = [];
const formArgs = parsedArguments.F || parsedArguments.form;
const formArray = Array.isArray(formArgs) ? formArgs : [formArgs];
formArray.forEach((multipartArgument) => {
// Parse each form field using regex:
// - Group 1: Field name before =
// - Group 2: Value in quotes after = (for text fields)
// - Group 3: Value after @ (for file fields)
const match = multipartArgument.match(/^([^=]+)=(?:@?"([^"]*)"|([^@]*))?$/);
if (match) {
const key = match[1];
const value = match[2] || match[3] || '';
const isFile = multipartArgument.includes('@');
multipartUploads.push({
name: key,
value: value,
type: isFile ? 'file' : 'text',
enabled: true
});
}
});
}
if (cookieString) {
const cookieParseOptions = {
decode: function (s) {
return s;
}
};
// separate out cookie headers into separate data structure
// note: cookie is case insensitive
cookies = cookie.parse(cookieString.replace(/^Cookie: /gi, ''), cookieParseOptions);
}
let method;
let parsedMethodArgument = parsedArguments.X || parsedArguments.request || parsedArguments.T;
if (parsedMethodArgument === 'POST') {
method = 'post';
} else if (parsedMethodArgument === 'PUT') {
method = 'put';
} else if (parsedMethodArgument === 'PATCH') {
method = 'patch';
} else if (parsedMethodArgument === 'DELETE') {
method = 'delete';
} else if (parsedMethodArgument === 'OPTIONS') {
method = 'options';
} else if (
(parsedArguments.d ||
parsedArguments.data ||
parsedArguments['data-ascii'] ||
parsedArguments['data-binary'] ||
parsedArguments['data-raw'] ||
parsedArguments.F ||
parsedArguments.form) &&
!(parsedArguments.G || parsedArguments.get)
) {
method = 'post';
} else if (parsedArguments.I || parsedArguments.head) {
method = 'head';
} else {
method = 'get';
}
const compressed = !!parsedArguments.compressed;
const urlObject = URL.parse(url || '');
// if GET request with data, convert data to query string
// NB: the -G flag does not change the http verb. It just moves the data into the url.
if (parsedArguments.G || parsedArguments.get) {
urlObject.query = urlObject.query ? urlObject.query : '';
let option = null;
if ('d' in parsedArguments) option = 'd';
if ('data' in parsedArguments) option = 'data';
if ('data-urlencode' in parsedArguments) option = 'data-urlencode';
if (option) {
let urlQueryString = '';
if (url.indexOf('?') < 0) {
url += '?';
} else {
urlQueryString += '&';
}
if (typeof parsedArguments[option] === 'object') {
urlQueryString += parsedArguments[option].join('&');
} else {
urlQueryString += parsedArguments[option];
}
urlObject.query += urlQueryString;
url += urlQueryString;
delete parsedArguments[option];
}
}
if (urlObject.query && urlObject.query.endsWith('&')) {
urlObject.query = urlObject.query.slice(0, -1);
}
const query = querystring.parse(urlObject.query, { sort: false });
for (const param in query) {
if (query[param] === null) {
query[param] = '';
}
}
urlObject.search = null; // Clean out the search/query portion.
let urlWithoutQuery = URL.format(urlObject);
let urlHost = urlObject?.host;
if (!url?.includes(`${urlHost}/`)) {
if (urlWithoutQuery && urlHost) {
const [beforeHost, afterHost] = urlWithoutQuery.split(urlHost);
urlWithoutQuery = beforeHost + urlHost + afterHost?.slice(1);
}
}
const request = {
url,
urlWithoutQuery
};
if (compressed) {
request.compressed = true;
}
if (Object.keys(query).length > 0) {
request.query = query;
}
if (headers) {
request.headers = headers;
}
request.method = method;
if (cookies) {
request.cookies = cookies;
request.cookieString = cookieString.replace('Cookie: ', '');
}
if (multipartUploads) {
request.multipartUploads = multipartUploads;
}
if (parsedArguments.data) {
request.data = parsedArguments.data;
} else if (parsedArguments['data-binary']) {
request.data = parsedArguments['data-binary'];
request.isDataBinary = true;
} else if (parsedArguments.d) {
request.data = parsedArguments.d;
} else if (parsedArguments['data-ascii']) {
request.data = parsedArguments['data-ascii'];
} else if (parsedArguments['data-raw']) {
request.data = parsedArguments['data-raw'];
request.isDataRaw = true;
} else if (parsedArguments['data-urlencode']) {
request.data = parsedArguments['data-urlencode'];
}
if (parsedArguments.user && typeof parsedArguments.user === 'string') {
const basicAuth = parsedArguments.user.split(':')
const username = basicAuth[0] || ''
const password = basicAuth[1] || ''
request.auth = {
mode: 'basic',
basic: {
username,
password
}
}
}
if (Array.isArray(request.data)) {
request.dataArray = request.data;
request.data = request.data.join('&');
}
if (parsedArguments.k || parsedArguments.insecure) {
request.insecure = true;
}
return request;
};
/**
* Process a single argument and return new state if needed
* State machine: flags set states, values are processed based on current state
*/
const processArgument = (arg, currentState, request) => {
// Handle flag arguments first (they set states)
const flagState = handleFlag(arg, request);
if (flagState) {
return flagState;
}
// Handle values based on current state (e.g., -H "value" where currentState is 'header')
if (arg && currentState) {
handleValue(arg, currentState, request);
return null;
}
// Handle URL detection (only when no current state to avoid conflicts)
if (!currentState && isURLOrFragment(arg)) {
setURL(request, arg);
return null;
}
return null;
};
/**
* Handle flag arguments and return new state
* Determines if flag expects a value or performs immediate action
*/
const handleFlag = (arg, request) => {
// Find which category this flag belongs to
for (const [category, flags] of Object.entries(FLAG_CATEGORIES)) {
if (flags.includes(arg)) {
return handleFlagCategory(category, arg, request);
}
}
return null;
};
/**
* Handle flag based on its category
* Returns state name for flags that expect values, null for immediate actions
*/
const handleFlagCategory = (category, arg, request) => {
switch (category) {
// State-returning flags (return category name to expect value)
case 'user-agent':
case 'header':
case 'data':
case 'json':
case 'user':
case 'method':
case 'cookie':
case 'form':
return category;
// Special data flags (set properties and return 'data' state)
case 'data-raw':
request.isDataRaw = true;
return 'data';
case 'data-binary':
request.isDataBinary = true;
return 'data';
// Immediate action flags (perform action and return null)
case 'head':
request.method = 'HEAD';
return null;
case 'compressed':
request.headers['Accept-Encoding'] = request.headers['Accept-Encoding'] || 'deflate, gzip';
return null;
case 'insecure':
request.insecure = true;
return null;
case 'query':
// set temporary property isQuery to true to indicate that the data should be converted to query string
// this is processed later at post build request processing
request.isQuery = true;
return null;
default:
return null;
}
};
/**
* Handle values based on the current parsing state
* Maps state names to their value processing functions
*/
const handleValue = (value, state, request) => {
const valueHandlers = {
'header': () => setHeader(request, value),
'user-agent': () => setUserAgent(request, value),
'data': () => setData(request, value),
'json': () => setJsonData(request, value),
'form': () => setFormData(request, value),
'user': () => setAuth(request, value),
'method': () => setMethod(request, value),
'cookie': () => setCookie(request, value)
};
const handler = valueHandlers[state];
if (handler) {
handler();
}
};
/**
* Set header from value
*/
const setHeader = (request, value) => {
const [headerName, headerValue] = value.split(/: (.+)/);
request.headers[headerName] = headerValue;
};
/**
* Set user agent
*/
const setUserAgent = (request, value) => {
request.headers['User-Agent'] = value;
};
/**
* Set authentication
*/
const setAuth = (request, value) => {
if (typeof value !== 'string') {
return;
}
const [username, password] = value.split(':');
request.auth = {
mode: 'basic',
basic: {
username: username || '',
password: password || ''
}
};
};
/**
* Set request method
*/
const setMethod = (request, value) => {
request.method = value.toUpperCase();
};
/**
* Set request cookies
*/
const setCookie = (request, value) => {
if (typeof value !== 'string') {
return;
}
const parsedCookies = cookie.parse(value);
request.cookies = { ...request.cookies, ...parsedCookies };
request.cookieString = request.cookieString ? request.cookieString + '; ' + value : value;
request.headers['Cookie'] = request.cookieString;
};
/**
* Set data (handles multiple -d flags by concatenating with &)
*/
const setData = (request, value) => {
request.data = request.data ? request.data + '&' + value : value;
};
/**
* Set JSON data
* JSON flag automatically sets Content-Type and converts GET/HEAD to POST
*/
const setJsonData = (request, value) => {
if (request.method === 'GET' || request.method === 'HEAD') {
request.method = 'POST';
}
request.headers['Content-Type'] = 'application/json';
// JSON data replaces existing data (don't append with &)
request.data = value;
};
/**
* Set form data
* Form data always sets method to POST and creates multipart uploads
*/
const setFormData = (request, value) => {
const formArray = Array.isArray(value) ? value : [value];
const multipartUploads = [];
formArray.forEach((field) => {
const upload = parseFormField(field);
if (upload) {
multipartUploads.push(upload);
}
});
request.multipartUploads = request.multipartUploads || [];
request.multipartUploads.push(...multipartUploads);
request.method = 'POST';
};
/**
* Parse a single form field
* Handles text fields, quoted values, and file uploads (@path)
*/
const parseFormField = (field) => {
const match = field.match(/^([^=]+)=(?:@?"([^"]*)"|@([^@]*)|([^@]*))?$/);
if (!match) return null;
const fieldName = match[1];
const fieldValue = match[2] || match[3] || match[4] || '';
const isFile = field.includes('@');
return {
name: fieldName,
value: fieldValue,
type: isFile ? 'file' : 'text',
enabled: true
};
};
/**
* Check if argument is a URL or URL fragment
*/
const isURLOrFragment = (arg) => {
return isURL(arg) || isURLFragment(arg);
};
/**
* Check if argument looks like a URL
*/
const isURL = (arg) => {
if (typeof arg !== 'string') {
return false;
}
return !!URL.parse(arg || '').host;
};
/**
* Check if argument looks like a URL fragment
* Handles shell-quote operator objects and query parameter patterns
*/
const isURLFragment = (arg) => {
if (arg && typeof arg === 'object' && arg.op === 'glob') {
return !!URL.parse(arg.pattern || '').host;
}
if (arg && typeof arg === 'object' && arg.op === '&') {
return true;
}
if (typeof arg === 'string') {
// check if arg is a query string containing key=value pair
return /^[^=]+=[^&]*$/.test(arg);
}
return false;
};
/**
* Set URL and related properties
* Handles URL concatenation for shell-quote fragments
*/
const setURL = (request, url) => {
const urlString = getUrlString(url);
if (!urlString) return;
const newUrl = request.url ? request.url + urlString : urlString;
const { url: formattedUrl, queries, urlWithoutQuery } = parseUrl(newUrl);
request.url = formattedUrl;
request.urlWithoutQuery = urlWithoutQuery;
request.query = queries;
};
/**
* Convert URL fragment to string
* Handles shell-quote operator objects
*/
const getUrlString = (url) => {
if (typeof url === 'string') return url;
if (url?.op === 'glob') return url.pattern;
if (url?.op === '&') return '&';
return null;
};
/**
* Parse URL
* Returns formatted URL, URL without query, and queries
*/
const parseUrl = (url) => {
const parsedUrl = URL.parse(url);
const queries = querystring.parse(parsedUrl.query, { sort: false });
// set empty string for null values
Object.entries(queries).forEach(([key, value]) => {
queries[key] = value ?? '';
});
let formattedUrl = URL.format(parsedUrl);
if (!url.endsWith('/') && formattedUrl.endsWith('/')) {
// Remove trailing slashes if origin url does not have a trailing slash
formattedUrl = formattedUrl.slice(0, -1);
}
const urlWithoutQuery = formattedUrl.split('?')[0];
return {
url: formattedUrl,
urlWithoutQuery,
queries
};
};
/**
* Convert data to query string
* Used when -G or --get flag is present to move data from body to URL
*/
const convertDataToQueryString = (request) => {
let url = request.url;
if (url.indexOf('?') < 0) {
url += '?';
} else if (!url.endsWith('&')) {
url += '&';
}
// append data to url as query string
url += request.data;
const { url: formattedUrl, queries } = parseUrl(url);
request.url = formattedUrl;
request.query = queries;
return request;
};
/**
* Post-build processing of request
* Handles method conversion and query parameter processing
*/
const postBuildProcessRequest = (request) => {
if (request.isQuery && request.data) {
request = convertDataToQueryString(request);
// remove data and isQuery from request as they are no longer needed
delete request.data;
delete request.isQuery;
} else if (request.data) {
// if data is present, set method to POST unless the method is explicitly set
if (!request.method || request.method === 'HEAD') {
request.method = 'POST';
}
}
// if method is not set, set it to GET
if (!request.method) {
request.method = 'GET';
}
// bruno requires method to be lowercase
request.method = request.method.toLowerCase();
return request;
};
/**
* Clean up the final request object
*/
const cleanRequest = (request) => {
if (isEmpty(request.headers)) {
delete request.headers;
}
if (isEmpty(request.query)) {
delete request.query;
}
return request;
};
/**
* Clean up curl command
* Handles escape sequences, line continuations, and method concatenation
*/
const cleanCurlCommand = (curlCommand) => {
// Handle escape sequences
curlCommand = curlCommand.replace(/\$('.*')/g, (match, group) => group);
// Convert escaped single quotes to shell quote pattern
curlCommand = curlCommand.replace(/\\'(?!')/g, "'\\''");
// Fix concatenated HTTP methods
curlCommand = fixConcatenatedMethods(curlCommand);
return curlCommand.trim();
};
/**
* Fix concatenated HTTP methods
* Eg: Converts -XPOST to -X POST for proper parsing
*/
const fixConcatenatedMethods = (command) => {
const methodFixes = [
{ from: / -XPOST/, to: ' -X POST' },
{ from: / -XGET/, to: ' -X GET' },
{ from: / -XPUT/, to: ' -X PUT' },
{ from: / -XPATCH/, to: ' -X PATCH' },
{ from: / -XDELETE/, to: ' -X DELETE' },
{ from: / -XOPTIONS/, to: ' -X OPTIONS' },
{ from: / -XHEAD/, to: ' -X HEAD' },
{ from: / -Xnull/, to: ' ' }
];
methodFixes.forEach(({ from, to }) => {
command = command.replace(from, to);
});
return command;
};
export default parseCurlCommand;

View File

@@ -2,144 +2,754 @@ const { describe, it, expect } = require('@jest/globals');
import parseCurlCommand from './parse-curl';
describe('parseCurlCommand', () => {
describe('basic functionality', () => {
it('should handle basic GET request', () => {
const result = parseCurlCommand('curl https://api.example.com/users');
describe('Basic HTTP Methods', () => {
it('should parse simple GET request', () => {
const result = parseCurlCommand(`
curl https://api.example.com/users
`);
expect(result).toEqual({
method: 'get',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users',
method: 'get'
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should parse explicit POST method', () => {
const result = parseCurlCommand('curl -X POST https://api.example.com/users');
const result = parseCurlCommand(`
curl -X POST https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users',
method: 'post'
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should parse PUT method', () => {
const result = parseCurlCommand(`
curl -X PUT https://api.example.com/users/1
`);
expect(result).toEqual({
method: 'put',
url: 'https://api.example.com/users/1',
urlWithoutQuery: 'https://api.example.com/users/1'
});
});
it('should parse DELETE method', () => {
const result = parseCurlCommand(`
curl -X DELETE https://api.example.com/users/1
`);
expect(result).toEqual({
method: 'delete',
url: 'https://api.example.com/users/1',
urlWithoutQuery: 'https://api.example.com/users/1'
});
});
it('should parse HEAD method', () => {
const result = parseCurlCommand(`
curl -I https://api.example.com/users
`);
expect(result).toEqual({
method: 'head',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
});
describe('headers handling', () => {
it('should parse multiple headers', () => {
const result = parseCurlCommand(
`curl -H 'Content-Type: application/json' -H 'Authorization: Bearer token' https://api.example.com`
);
describe('Headers', () => {
it('should parse single header', () => {
const result = parseCurlCommand(`
curl --header "Content-Type: application/json" https://api.example.com
`);
expect(result).toEqual({
method: 'get',
headers: {
'Content-Type': 'application/json'
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
it('should parse multiple headers', () => {
const result = parseCurlCommand(`
curl -H "Content-Type: application/json" \
-H "Authorization: Bearer token" \
https://api.example.com
`);
expect(result).toEqual({
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token'
}
'Authorization': 'Bearer token'
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
it('should parse user-agent', () => {
const result = parseCurlCommand(`curl -A 'Custom Agent' https://api.example.com`);
it('should parse user-agent header', () => {
const result = parseCurlCommand(`
curl -A "Custom User Agent" https://api.example.com
`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
headers: {
'User-Agent': 'Custom Agent'
}
'User-Agent': 'Custom User Agent'
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
});
describe('auth handling', () => {
it('should parse basic auth', () => {
const result = parseCurlCommand(`curl -u user:pass https://api.example.com`);
describe('Data and Request Body', () => {
it('should parse JSON data and change method to POST', () => {
const result = parseCurlCommand(`
curl -d '{"name": "John", "age": 30}' https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
data: '{"name": "John", "age": 30}',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should parse post data', () => {
const result = parseCurlCommand(`
curl --data "name=John&age=30" https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
data: 'name=John&age=30',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should handle multiple data flags', () => {
const result = parseCurlCommand(`
curl -d "name=John" \
-d "age=30" \
https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
data: 'name=John&age=30',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should keep multiline data', () => {
const result = parseCurlCommand(`
curl -d '{"key": "some long message with line breaks
multiline"}' \
https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
data: `{"key": "some long message with line breaks
multiline"}`,
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should keep multi space data', () => {
const result = parseCurlCommand(`
curl -d '{"key": "some long spaced message"}' \
https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
data: '{"key": "some long spaced message"}',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should parse binary data flag', () => {
const result = parseCurlCommand(`
curl --data-binary "@/path/to/file" https://api.example.com/upload
`);
expect(result).toEqual({
method: 'post',
data: '@/path/to/file',
isDataBinary: true,
url: 'https://api.example.com/upload',
urlWithoutQuery: 'https://api.example.com/upload'
});
});
it('should parse raw data flag', () => {
const result = parseCurlCommand(`
curl --data-raw '{"raw": "data"}' https://api.example.com
`);
expect(result).toEqual({
method: 'post',
data: '{"raw": "data"}',
isDataRaw: true,
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
});
describe('Authentication', () => {
it('should parse basic authentication', () => {
const result = parseCurlCommand(`
curl -u "username:password" https://api.example.com
`);
expect(result).toEqual({
method: 'get',
auth: {
mode: 'basic',
basic: {
username: 'user',
password: 'pass'
username: 'username',
password: 'password'
}
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
it('should handle username without password', () => {
const result = parseCurlCommand(`
curl --user "username" https://api.example.com
`);
expect(result).toEqual({
method: 'get',
auth: {
mode: 'basic',
basic: {
username: 'username',
password: ''
}
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
});
describe('Form Data', () => {
it('should parse form data with text fields', () => {
const result = parseCurlCommand(`
curl -F "name=John" \
-F "age=30" \
https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
multipartUploads: [
{ name: 'name', value: 'John', type: 'text', enabled: true },
{ name: 'age', value: '30', type: 'text', enabled: true }
],
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should parse form data with file uploads', () => {
const result = parseCurlCommand(`
curl --form "file=@/path/to/file.txt" https://api.example.com/upload
`);
expect(result).toEqual({
method: 'post',
multipartUploads: [
{ name: 'file', value: '/path/to/file.txt', type: 'file', enabled: true }
],
url: 'https://api.example.com/upload',
urlWithoutQuery: 'https://api.example.com/upload'
});
});
});
describe('Cookie', () => {
it('should handle cookie flag', () => {
const result = parseCurlCommand(`
curl -b "session=abc123" https://api.example.com
`);
expect(result).toEqual({
method: 'get',
headers: {
'Cookie': 'session=abc123'
},
cookieString: "session=abc123",
cookies: {
session: 'abc123'
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
it('should handle cookie flag with multiple cookies', () => {
const result = parseCurlCommand(`
curl -b "session=abc123; user=john" https://api.example.com
`);
expect(result).toEqual({
method: 'get',
headers: {
'Cookie': 'session=abc123; user=john'
},
cookieString: "session=abc123; user=john",
cookies: {
session: 'abc123',
user: 'john'
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
it('should handle multiple cookie flags', () => {
const result = parseCurlCommand(`
curl -b "session=abc123" -b "user=john" https://api.example.com
`);
expect(result).toEqual({
method: 'get',
headers: {
'Cookie': 'session=abc123; user=john'
},
cookieString: "session=abc123; user=john",
cookies: {
session: 'abc123',
user: 'john'
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
it('should handle complex cookie string', () => {
const result = parseCurlCommand(`
curl -b "session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly" \
https://api.example.com
`);
expect(result).toEqual({
method: 'get',
headers: {
'Cookie': 'session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly'
},
cookieString: "session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly",
cookies: {
session: 'abc123',
user: 'john',
path: '/',
domain: 'example.com',
expires: 'Thu, 01 Jan 1970 00:00:00 GMT',
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
});
describe('Shell Quote Handling', () => {
it(`should handle shell quote patterns ('\'' => \')`, () => {
const result = parseCurlCommand(`
curl -d '{"name": "John\'\\'\'s data"}' https://api.example.com
`);
expect(result).toEqual({
method: 'post',
data: '{"name": "John\'s data"}',
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
it('should handle complex escaped quotes', () => {
const result = parseCurlCommand(`
curl -d '{"message": "Don\\'t stop believing"}' https://api.example.com
`);
expect(result).toEqual({
method: 'post',
data: '{"message": "Don\'t stop believing"}',
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
});
describe('URL Handling', () => {
it('should parse URLs with query parameters', () => {
const result = parseCurlCommand(`
curl https://api.example.com/users?page=1&limit=10&sort=asc
`);
expect(result).toEqual({
method: 'get',
query: {
page: '1',
limit: '10',
sort: 'asc'
},
url: 'https://api.example.com/users?page=1&limit=10&sort=asc',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should handle URLs with paths', () => {
const result = parseCurlCommand(`
curl https://api.example.com/v1/users/123
`);
expect(result).toEqual({
method: 'get',
url: 'https://api.example.com/v1/users/123',
urlWithoutQuery: 'https://api.example.com/v1/users/123'
});
});
});
describe('Edge Cases', () => {
it('should handle compressed flag', () => {
const result = parseCurlCommand(`
curl --compressed https://api.example.com
`);
expect(result).toEqual({
method: 'get',
headers: {
'Accept-Encoding': 'deflate, gzip'
},
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
it('should handle concatenated HTTP methods', () => {
const result = parseCurlCommand(`
curl -XPOST https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should handle newlines and continuations', () => {
const result = parseCurlCommand(`
curl -H "Content-Type: application/json" \
-d '{"name": "John"}' \
https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
headers: {
'Content-Type': 'application/json'
},
data: '{"name": "John"}',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
});
describe('Complex Examples', () => {
it('should parse a complex curl command with multiple features', () => {
const result = parseCurlCommand(`
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer token123" \
-H "X-Custom-Header: custom header" \
-d '{"name": "John\\'s data", "email": "john@example.com", "message": "Don\\'t stop believing!", "path": "/home/user/file.txt", "json": {"nested": "value", "array": [1, 2, 3]}}' \
-u "api_user:api_pass" \
--compressed \
https://api.example.com/v1/users?param1=value1&param2=custom+param
`);
expect(result).toEqual({
method: 'post',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123',
'X-Custom-Header': 'custom header',
'Accept-Encoding': 'deflate, gzip'
},
data: '{"name": "John\'s data", "email": "john@example.com", "message": "Don\'t stop believing!", "path": "/home/user/file.txt", "json": {"nested": "value", "array": [1, 2, 3]}}',
auth: {
mode: 'basic',
basic: {
username: 'api_user',
password: 'api_pass'
}
},
query: {
param1: 'value1',
param2: 'custom param'
},
url: 'https://api.example.com/v1/users?param1=value1&param2=custom+param',
urlWithoutQuery: 'https://api.example.com/v1/users'
});
});
});
describe('curl command with complex escape characters', () => {
it('should parse a curl command with complex escape characters', () => {
const result = parseCurlCommand(`
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer token123" \
-d '{"name": "John\\'s data", "email": "john@example.com"}' \
-u "api_user:api_pass" \
--compressed \
https://api.example.com/v1/users
`);
expect(result).toEqual({
method: 'post',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123',
'Accept-Encoding': 'deflate, gzip'
},
data: '{"name": "John\'s data", "email": "john@example.com"}',
auth: {
mode: 'basic',
basic: {
username: 'api_user',
password: 'api_pass'
}
},
url: 'https://api.example.com/v1/users',
urlWithoutQuery: 'https://api.example.com/v1/users'
});
});
});
describe('JSON Flag', () => {
it('should handle basic JSON request', () => {
const result = parseCurlCommand(`
curl --json '{"name": "John Doe", "email": "john@example.com"}' \
https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
headers: {
'Content-Type': 'application/json'
},
data: '{"name": "John Doe", "email": "john@example.com"}',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should handle JSON with authentication headers', () => {
const result = parseCurlCommand(`
curl --json '{"title": "New Post", "content": "Post content"}' \
-H "Authorization: Bearer token123" \
https://api.example.com/posts
`);
expect(result).toEqual({
method: 'post',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123'
},
data: '{"title": "New Post", "content": "Post content"}',
url: 'https://api.example.com/posts',
urlWithoutQuery: 'https://api.example.com/posts'
});
});
it('should handle complex JSON data', () => {
const result = parseCurlCommand(`
curl --json '{"user": {"name": "Jane", "email": "jane@example.com"}, "metadata": {"source": "web"}}' \
https://api.example.com/users
`);
expect(result).toEqual({
method: 'post',
headers: {
'Content-Type': 'application/json'
},
data: '{"user": {"name": "Jane", "email": "jane@example.com"}, "metadata": {"source": "web"}}',
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users'
});
});
it('should handle JSON with escaped quotes', () => {
const result = parseCurlCommand(`
curl --json '{"message": "Don\\'t stop believing!", "user": "John\\'s account"}' \
https://api.example.com/messages
`);
expect(result).toEqual({
method: 'post',
headers: {
'Content-Type': 'application/json'
},
data: '{"message": "Don\'t stop believing!", "user": "John\'s account"}',
url: 'https://api.example.com/messages',
urlWithoutQuery: 'https://api.example.com/messages'
});
});
it('should handle JSON with arrays and nested objects', () => {
const result = parseCurlCommand(`
curl --json '{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}], "total": 2}' \
https://api.example.com/orders
`);
expect(result).toEqual({
method: 'post',
headers: {
'Content-Type': 'application/json'
},
data: '{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}], "total": 2}',
url: 'https://api.example.com/orders',
urlWithoutQuery: 'https://api.example.com/orders'
});
});
it('should handle JSON with custom method', () => {
const result = parseCurlCommand(`
curl -X PUT \
--json '{"status": "completed", "updated_at": "2024-01-15T10:30:00Z"}' \
https://api.example.com/tasks/123
`);
expect(result).toEqual({
method: 'put',
headers: {
'Content-Type': 'application/json'
},
data: '{"status": "completed", "updated_at": "2024-01-15T10:30:00Z"}',
url: 'https://api.example.com/tasks/123',
urlWithoutQuery: 'https://api.example.com/tasks/123'
});
});
});
describe('Insecure Flag', () => {
it('should handle -k flag', () => {
const result = parseCurlCommand(`
curl -k https://api.example.com
`);
expect(result).toEqual({
method: 'get',
insecure: true,
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
it('should handle --insecure flag', () => {
const result = parseCurlCommand(`
curl --insecure https://api.example.com
`);
expect(result).toEqual({
method: 'get',
insecure: true,
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com'
});
});
});
describe('Query Flag', () => {
it('should handle -G flag to convert POST data to GET query parameters', () => {
const result = parseCurlCommand(`
curl -G -d "name=John" -d "age=30" https://api.example.com/users
`);
expect(result).toEqual({
method: 'get',
url: 'https://api.example.com/users?name=John&age=30',
urlWithoutQuery: 'https://api.example.com/users',
query: {
name: 'John',
age: '30'
}
});
});
it('should handle -G flag with --data-urlencode', () => {
const result = parseCurlCommand(`
curl -G --data-urlencode "name=John Doe" \
--data-urlencode "email=john@example.com" \
--data-urlencode "hello" \
https://api.example.com/users?test=urlquery&hello
`);
expect(result).toEqual({
method: 'get',
url: 'https://api.example.com/users?test=urlquery&name=John%20Doe&email=john@example.com&hello',
urlWithoutQuery: 'https://api.example.com/users',
query: {
email: 'john@example.com',
hello: '',
name: 'John Doe',
test: 'urlquery'
}
});
});
it('should handle -G flag with complex data', () => {
const result = parseCurlCommand(`
curl -G -d "search=test+query" \
-d "filter=active" \
-d "sort=name" \
-d "page=1" \
https://api.example.com/search
`);
expect(result).toEqual({
method: 'get',
url: 'https://api.example.com/search?search=test+query&filter=active&sort=name&page=1',
urlWithoutQuery: 'https://api.example.com/search',
query: {
search: 'test query',
filter: 'active',
sort: 'name',
page: '1'
}
});
});
});
describe('data handling', () => {
it('should parse POST data', () => {
const result = parseCurlCommand(`curl -d 'foo=bar&baz=qux' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'post',
data: 'foo=bar&baz=qux'
});
});
it('should handle data-binary', () => {
const result = parseCurlCommand(`curl --data-binary '@file.json' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'post',
data: '@file.json',
isDataBinary: true
});
});
});
describe('form data handling', () => {
it('should parse complex form data with multiple fields and file upload', () => {
const curlCommand = `curl --location 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d' \
--form 'id="1"' \
--form 'documentid="ADMINN_ID"' \
--form 'appoinID="12376"' \
--form 'autoclose="false"' \
--form 'fileData=@"/path/to/file"'`;
const result = parseCurlCommand(curlCommand);
expect(result).toEqual({
url: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
urlWithoutQuery: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
method: 'post',
multipartUploads: [
{
name: 'id',
value: '1',
type: 'text',
enabled: true
},
{
name: 'documentid',
value: 'ADMINN_ID',
type: 'text',
enabled: true
},
{
name: 'appoinID',
value: '12376',
type: 'text',
enabled: true
},
{
name: 'autoclose',
value: 'false',
type: 'text',
enabled: true
},
{
name: 'fileData',
value: '/path/to/file',
type: 'file',
enabled: true
}
]
});
});
});
});