Merge branch 'main' into feature/jsonpath-filtering

This commit is contained in:
Boris Baskovec 2023-11-08 22:43:27 +01:00
commit 25af7a211a
11 changed files with 972 additions and 174 deletions

526
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,10 +20,11 @@
"@tippyjs/react": "^4.2.6",
"@usebruno/graphql-docs": "0.1.0",
"@usebruno/schema": "0.6.0",
"axios": "^0.26.0",
"axios": "^1.5.1",
"classnames": "^2.3.1",
"codemirror": "^5.65.2",
"codemirror-graphql": "^1.2.5",
"cookie": "^0.6.0",
"escape-html": "^1.0.3",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.5",
@ -36,6 +37,7 @@
"httpsnippet": "^3.0.1",
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",
"jsonpath-plus": "^7.2.0",
"know-your-http-well": "^0.5.0",
"lodash": "^4.17.21",
@ -43,11 +45,11 @@
"mousetrap": "^1.6.5",
"nanoid": "3.3.4",
"next": "12.3.3",
"parse-curl": "^0.2.6",
"path": "^0.12.7",
"platform": "^1.3.6",
"posthog-node": "^2.1.0",
"qs": "^6.11.0",
"query-string": "^7.0.1",
"react": "18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@ -60,7 +62,9 @@
"sass": "^1.46.0",
"styled-components": "^5.3.3",
"tailwindcss": "^2.2.19",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1",
"yup": "^0.32.11"
},
"devDependencies": {

View File

@ -1,5 +1,4 @@
import get from 'lodash/get';
import isString from 'lodash/isString';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;

View File

@ -94,3 +94,15 @@ export const getContentType = (headers) => {
return '';
};
export const startsWith = (str, search) => {
if (!str || !str.length || typeof str !== 'string') {
return false;
}
if (!search || !search.length || typeof search !== 'string') {
return false;
}
return str.substr(0, search.length) === search;
};

View File

@ -1,6 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
import { normalizeFileName } from './index';
import { normalizeFileName, startsWith } from './index';
describe('common utils', () => {
describe('normalizeFileName', () => {
@ -16,4 +16,37 @@ describe('common utils', () => {
expect(normalizeFileName('foo\\bar\\')).toBe('foo-bar-');
});
});
describe('startsWith', () => {
it('should return false if str is not a string', () => {
expect(startsWith(null, 'foo')).toBe(false);
expect(startsWith(undefined, 'foo')).toBe(false);
expect(startsWith(123, 'foo')).toBe(false);
expect(startsWith({}, 'foo')).toBe(false);
expect(startsWith([], 'foo')).toBe(false);
});
it('should return false if search is not a string', () => {
expect(startsWith('foo', null)).toBe(false);
expect(startsWith('foo', undefined)).toBe(false);
expect(startsWith('foo', 123)).toBe(false);
expect(startsWith('foo', {})).toBe(false);
expect(startsWith('foo', [])).toBe(false);
});
it('should return false if str does not start with search', () => {
expect(startsWith('foo', 'bar')).toBe(false);
expect(startsWith('foo', 'baz')).toBe(false);
expect(startsWith('foo', 'bar')).toBe(false);
expect(startsWith('foo', 'baz')).toBe(false);
expect(startsWith('foo', 'bar')).toBe(false);
expect(startsWith('foo', 'baz')).toBe(false);
});
it('should return true if str starts with search', () => {
expect(startsWith('foo', 'f')).toBe(true);
expect(startsWith('foo', 'fo')).toBe(true);
expect(startsWith('foo', 'foo')).toBe(true);
});
});
});

View File

@ -0,0 +1,169 @@
/**
* 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.
*/
import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
return contentType ? headers[contentType] : null;
}
function repr(value, isKey) {
return isKey ? "'" + jsesc(value, { quotes: 'single' }) + "'" : value;
}
function getQueries(request) {
const queries = {};
for (const paramName in request.query) {
const rawValue = request.query[paramName];
let paramValue;
if (Array.isArray(rawValue)) {
paramValue = rawValue.map(repr);
} else {
paramValue = repr(rawValue);
}
queries[repr(paramName)] = paramValue;
}
return queries;
}
function getDataString(request) {
if (typeof request.data === 'number') {
request.data = request.data.toString();
}
const contentType = getContentType(request.headers);
if (contentType && contentType.includes('application/json')) {
return { data: request.data.toString() };
}
const parsedQueryString = querystring.parse(request.data, { sort: false });
const keyCount = Object.keys(parsedQueryString).length;
const singleKeyOnly = keyCount === 1 && !parsedQueryString[Object.keys(parsedQueryString)[0]];
const singularData = request.isDataBinary || singleKeyOnly;
if (singularData) {
const data = {};
data[repr(request.data)] = '';
return { data: data };
} else {
return getMultipleDataString(request, parsedQueryString);
}
}
function getMultipleDataString(request, parsedQueryString) {
const data = {};
for (const key in parsedQueryString) {
const value = parsedQueryString[key];
if (Array.isArray(value)) {
data[repr(key)] = value;
} else {
data[repr(key)] = repr(value);
}
}
return { data: data };
}
function getFilesString(request) {
const data = {};
data.files = {};
data.data = {};
for (const multipartKey in request.multipartUploads) {
const multipartValue = request.multipartUploads[multipartKey];
if (multipartValue.startsWith('@')) {
const fileName = multipartValue.slice(1);
data.files[repr(multipartKey)] = repr(fileName);
} else {
data.data[repr(multipartKey)] = repr(multipartValue);
}
}
if (Object.keys(data.files).length === 0) {
delete data.files;
}
if (Object.keys(data.data).length === 0) {
delete data.data;
}
return data;
}
const curlToJson = (curlCommand) => {
const request = parseCurlCommand(curlCommand);
const requestJson = {};
// curl automatically prepends 'http' if the scheme is missing, but python fails and returns an error
// we tack it on here to mimic curl
if (!request.url.match(/https?:/)) {
request.url = 'http://' + request.url;
}
if (!request.urlWithoutQuery.match(/https?:/)) {
request.urlWithoutQuery = 'http://' + request.urlWithoutQuery;
}
requestJson.url = request.urlWithoutQuery.replace(/\/$/, '');
requestJson.raw_url = request.url;
requestJson.method = request.method;
if (request.cookies) {
const cookies = {};
for (const cookieName in request.cookies) {
cookies[repr(cookieName)] = repr(request.cookies[cookieName]);
}
requestJson.cookies = cookies;
}
if (request.headers) {
const headers = {};
for (const headerName in request.headers) {
headers[repr(headerName)] = repr(request.headers[headerName]);
}
requestJson.headers = headers;
}
if (request.query) {
requestJson.queries = getQueries(request);
}
if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));
} else if (request.multipartUploads) {
Object.assign(requestJson, getFilesString(request));
}
if (request.insecure) {
requestJson.insecure = false;
}
if (request.auth) {
const splitAuth = request.auth.split(':');
const user = splitAuth[0] || '';
const password = splitAuth[1] || '';
requestJson.auth = {
user: repr(user),
password: repr(password)
};
}
return Object.keys(requestJson).length ? requestJson : {};
};
export default curlToJson;

View File

@ -0,0 +1,62 @@
const { describe, it, expect } = require('@jest/globals');
import curlToJson from './curl-to-json';
describe('curlToJson', () => {
it('should return a parse a simple curl command', () => {
const curlCommand = 'curl https://www.usebruno.com';
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://www.usebruno.com',
raw_url: 'https://www.usebruno.com',
method: 'get'
});
});
it('should return a parse a curl command with headers', () => {
const curlCommand = `curl https://www.usebruno.com
-H 'Accept: application/json, text/plain, */*'
-H 'Accept-Language: en-US,en;q=0.9,hi;q=0.8'
`;
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://www.usebruno.com',
raw_url: 'https://www.usebruno.com',
method: 'get',
headers: {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8'
}
});
});
it('should return a parse a curl with a post body', () => {
const curlCommand = `curl 'https://www.usebruno.com'
-H 'Accept: application/json, text/plain, */*'
-H 'Accept-Language: en-US,en;q=0.9,hi;q=0.8'
-H 'Content-Type: application/json;charset=utf-8'
-H 'Origin: https://www.usebruno.com'
-H 'Referer: https://www.usebruno.com/'
--data-raw '{"email":"test@usebruno.com","password":"test"}'
`;
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: '%27https://www.usebruno.com%27',
raw_url: "'https://www.usebruno.com'",
method: 'post',
headers: {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
'Content-Type': 'application/json;charset=utf-8',
Origin: 'https://www.usebruno.com',
Referer: 'https://www.usebruno.com/'
},
data: '{"email":"test@usebruno.com","password":"test"}'
});
});
});

View File

@ -1,18 +1,29 @@
import * as parse from 'parse-curl';
import { BrunoError } from 'utils/common/error';
import { parseQueryParams } from 'utils/url';
import { forOwn } from 'lodash';
import { safeStringifyJSON } from 'utils/common';
import curlToJson from './curl-to-json';
export const getRequestFromCurlCommand = (command) => {
export const getRequestFromCurlCommand = (curlCommand) => {
const parseFormData = (parsedBody) => {
parseQueryParams(parsedBody);
};
try {
const request = parse(command);
const parsedHeader = request?.header;
const headers =
parsedHeader && Object.keys(parsedHeader).map((key) => ({ name: key, value: parsedHeader[key], enabled: true }));
const formData = [];
forOwn(parsedBody, (value, key) => {
formData.push({ name: key, value, enabled: true });
});
const contentType = headers?.find((h) => h.name.toLowerCase() === 'content-type');
return formData;
};
try {
if (!curlCommand || typeof curlCommand !== 'string' || curlCommand.length === 0) {
return null;
}
const request = curlToJson(curlCommand);
const parsedHeaders = request?.headers;
const headers =
parsedHeaders &&
Object.keys(parsedHeaders).map((key) => ({ name: key, value: parsedHeaders[key], enabled: true }));
const contentType = headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value;
const body = {
mode: 'none',
json: null,
@ -22,30 +33,25 @@ export const getRequestFromCurlCommand = (command) => {
multipartForm: null,
formUrlEncoded: null
};
const parsedBody = request?.body;
if (parsedBody && contentType) {
switch (contentType.value.toLowerCase()) {
case 'application/json':
body.mode = 'json';
body.json = parsedBody;
break;
case 'text/xml':
body.mode = 'xml';
body.xml = parsedBody;
break;
case 'application/x-www-form-urlencoded':
body.mode = 'formUrlEncoded';
body.formUrlEncoded = parseFormData(parsedBody);
break;
case 'multipart/form-data':
body.mode = 'multipartForm';
body.multipartForm = parsedBody;
break;
case 'text/plain':
default:
body.mode = 'text';
body.text = parsedBody;
break;
const parsedBody = request.data;
if (parsedBody && contentType && typeof contentType === 'string') {
if (contentType.includes('application/json')) {
body.mode = 'json';
body.json = safeStringifyJSON(parsedBody);
} else if (contentType.includes('text/xml')) {
body.mode = 'xml';
body.xml = parsedBody;
} else if (contentType.includes('application/x-www-form-urlencoded')) {
body.mode = 'formUrlEncoded';
console.log(parsedBody);
console.log(parseFormData(parsedBody));
body.formUrlEncoded = parseFormData(parsedBody);
} else if (contentType.includes('multipart/form-data')) {
body.mode = 'multipartForm';
body.multipartForm = parsedBody;
} else if (contentType.includes('text/plain')) {
body.mode = 'text';
body.text = parsedBody;
}
}
return {
@ -55,6 +61,7 @@ export const getRequestFromCurlCommand = (command) => {
headers: headers
};
} catch (error) {
console.error(error);
return null;
}
};

View File

@ -0,0 +1,238 @@
/**
* 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.
*/
import * as cookie from 'cookie';
import * as URL from 'url';
import * as querystring from 'query-string';
import yargs from 'yargs-parser';
const parseCurlCommand = (curlCommand) => {
// Remove newlines (and from continuations)
curlCommand = curlCommand.replace(/\\\r|\\\n/g, '');
// Remove extra whitespace
curlCommand = curlCommand.replace(/\s+/g, ' ');
// 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'],
alias: {
H: 'header',
A: 'user-agent'
}
});
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];
}
}
}
}
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;
} else {
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;
if (parsedArguments.F) {
multipartUploads = {};
if (!Array.isArray(parsedArguments.F)) {
parsedArguments.F = [parsedArguments.F];
}
parsedArguments.F.forEach((multipartArgument) => {
// input looks like key=value. value could be json or a file path prepended with an @
const splitArguments = multipartArgument.split('=', 2);
const key = splitArguments[0];
const value = splitArguments[1];
multipartUploads[key] = value;
});
}
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;
if (parsedArguments.X === 'POST') {
method = 'post';
} else if (parsedArguments.X === 'PUT' || parsedArguments.T) {
method = 'put';
} else if (parsedArguments.X === 'PATCH') {
method = 'patch';
} else if (parsedArguments.X === 'DELETE') {
method = 'delete';
} else if (parsedArguments.X === '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 : '';
const option = 'd' in parsedArguments ? 'd' : 'data' in parsedArguments ? 'data' : null;
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.
const request = {
url: url,
urlWithoutQuery: URL.format(urlObject)
};
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;
}
if (parsedArguments.u) {
request.auth = parsedArguments.u;
}
if (parsedArguments.user) {
request.auth = parsedArguments.user;
}
if (Array.isArray(request.data)) {
request.dataArray = request.data;
request.data = request.data.join('&');
}
if (parsedArguments.k || parsedArguments.insecure) {
request.insecure = true;
}
return request;
};
export default parseCurlCommand;

View File

@ -14,16 +14,18 @@ const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window')
const lastOpenedCollections = new LastOpenedCollections();
// Reference: https://content-security-policy.com/
const contentSecurityPolicy = [
isDev ? "default-src 'self' 'unsafe-inline' 'unsafe-eval'" : "default-src 'self'",
"connect-src 'self' https://api.github.com/repos/usebruno/bruno",
"font-src 'self' https://fonts.gstatic.com",
"default-src 'self'",
"script-src * 'unsafe-inline' 'unsafe-eval'",
"connect-src 'self' api.github.com",
"font-src 'self' https:",
"form-action 'none'",
"img-src 'self' blob: data:",
"style-src 'self' https://fonts.googleapis.com"
"img-src 'self' blob: data: https:",
"style-src 'self' 'unsafe-inline' https:"
];
setContentSecurityPolicy(contentSecurityPolicy.join(';'));
setContentSecurityPolicy(contentSecurityPolicy.join(';') + ';');
const menu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menu);

View File

@ -17,7 +17,7 @@
"@usebruno/query": "0.1.0",
"ajv": "^8.12.0",
"atob": "^2.1.2",
"axios": "^0.26.0",
"axios": "^1.5.1",
"btoa": "^1.2.1",
"chai": "^4.3.7",
"chai-string": "^1.5.0",