diff --git a/packages/bruno-app/src/components/FilePickerEditor/index.js b/packages/bruno-app/src/components/FilePickerEditor/index.js index 797771bbb..d976a3e79 100644 --- a/packages/bruno-app/src/components/FilePickerEditor/index.js +++ b/packages/bruno-app/src/components/FilePickerEditor/index.js @@ -6,7 +6,7 @@ import { IconX } from '@tabler/icons'; import { isWindowsOS } from 'utils/common/platform'; import slash from 'utils/common/slash'; -const FilePickerEditor = ({ value, onChange, collection }) => { +const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false}) => { value = value || []; const dispatch = useDispatch(); const filenames = value @@ -20,7 +20,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => { const title = filenames.map((v) => `- ${v}`).join('\n'); const browse = () => { - dispatch(browseFiles()) + dispatch(browseFiles([],[''])) .then((filePaths) => { // If file is in the collection's directory, then we use relative path // Otherwise, we use the absolute path @@ -49,7 +49,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => { if (filenames.length == 1) { return filenames[0]; } - return filenames.length + ' files selected'; + return filenames.length + ' file(s) selected'; }; return filenames.length > 0 ? ( @@ -66,7 +66,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => { ) : ( ); }; diff --git a/packages/bruno-app/src/components/RequestPane/Binary/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Binary/StyledWrapper.js new file mode 100644 index 000000000..35adfcc1f --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Binary/StyledWrapper.js @@ -0,0 +1,65 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + table { + width: 100%; + border-collapse: collapse; + font-weight: 600; + table-layout: fixed; + + thead, + td { + border: 1px solid ${(props) => props.theme.table.border}; + } + + thead { + color: ${(props) => props.theme.table.thead.color}; + font-size: 0.8125rem; + user-select: none; + } + td { + padding: 6px 10px; + + &:nth-child(1) { + width: 30%; + } + + &:nth-child(2) { + width: 45%; + } + + &:nth-child(3) { + width: 25%; + } + + &:nth-child(4) { + width: 70px; + } + } + } + + .btn-add-param { + font-size: 0.8125rem; + } + + input[type='text'] { + width: 100%; + border: solid 1px transparent; + outline: none !important; + color: ${(props) => props.theme.table.input.color}; + background: transparent; + + &:focus { + outline: none !important; + border: solid 1px transparent; + } + } + + input[type='radio'] { + cursor: pointer; + position: relative; + top: 1px; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/RequestPane/Binary/index.js b/packages/bruno-app/src/components/RequestPane/Binary/index.js new file mode 100644 index 000000000..77bbda8d5 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Binary/index.js @@ -0,0 +1,173 @@ +import React from 'react'; +import get from 'lodash/get'; +import cloneDeep from 'lodash/cloneDeep'; +import { IconTrash } from '@tabler/icons'; +import { useDispatch } from 'react-redux'; +import { useTheme } from 'providers/Theme'; +import { + addBinaryFile, + updateBinaryFile, + deleteBinaryFile +} from 'providers/ReduxStore/slices/collections'; +import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import StyledWrapper from './StyledWrapper'; +import FilePickerEditor from 'components/FilePickerEditor'; +import SingleLineEditor from 'components/SingleLineEditor/index'; +import { isArray } from 'lodash'; +import path from 'node:path'; +import { useState } from 'react'; + +const Binary = ({ item, collection }) => { + const dispatch = useDispatch(); + const { storedTheme } = useTheme(); + const params = item.draft ? get(item, 'draft.request.body.binaryFile') : get(item, 'request.body.binaryFile'); + + const [enabledFileUid, setEnableFileUid] = useState(params && params.length ? params[0].uid : ''); + + const addFile = () => { + dispatch( + addBinaryFile({ + itemUid: item.uid, + collectionUid: collection.uid, + type: 'binaryFile', + value: [''], + }) + ); + }; + + const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); + const handleRun = () => dispatch(sendRequest(item, collection.uid)); + + const handleParamChange = (e, _param, type) => { + + const param = cloneDeep(_param); + + switch (type) { + + case 'value': { + param.value = isArray(e.target.value) && e.target.value.length > 0 ? e.target.value : ['']; + param.name = param.value.length === 0 ? '': path.basename(param.value[0], path.extname(param.value[0])); + break; + } + case 'contentType': { + param.contentType = e.target.value; + break; + } + case 'enabled': { + param.enabled = e.target.checked; + + setEnableFileUid(param.uid); + + break; + } + } + dispatch( + updateBinaryFile({ + param: param, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + const handleRemoveParams = (param) => { + dispatch( + deleteBinaryFile({ + paramUid: param.uid, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + return ( + + + + + + + + + + + + {params && params.length + ? params.map((param, index) => { + return ( + + + + + + + ); + }) + : null} + +
File
Content-Type
Enabled
+ + handleParamChange( + { + target: { + value: newValue + } + }, + param, + 'value' + ) + } + collection={collection} + /> + + + handleParamChange( + { + target: { + value: newValue + } + }, + param, + 'contentType' + ) + } + onRun={handleRun} + collection={collection} + /> + +
+ handleParamChange(e, param, 'enabled')} + /> +
+
+
+ +
+
+
+ +
+
+ ); +}; +export default Binary; diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js index 29b66d58d..95b3b6a55 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js @@ -128,6 +128,15 @@ const RequestBodyMode = ({ item, collection }) => { SPARQL
Other
+
{ + dropdownTippyRef.current.hide(); + onModeChange('binaryFile'); + }} + > + Binary File +
{ diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js index ca60c8662..9a71a4ac3 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js @@ -8,6 +8,7 @@ import { useTheme } from 'providers/Theme'; import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; +import Binary from '../Binary/index'; const RequestBody = ({ item, collection }) => { const dispatch = useDispatch(); @@ -62,6 +63,10 @@ const RequestBody = ({ item, collection }) => { ); } + if (bodyMode === 'binaryFile') { + return + } + if (bodyMode === 'formUrlEncoded') { return ; } diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 75c6f2cb9..0737cb133 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -759,7 +759,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => { xml: null, sparql: null, multipartForm: null, - formUrlEncoded: null + formUrlEncoded: null, + binaryFile: null }, auth: auth ?? { mode: 'none' @@ -1039,12 +1040,12 @@ export const browseDirectory = () => (dispatch, getState) => { }; export const browseFiles = - (filters = []) => + (filters = [], properties = ['multiSelections']) => (dispatch, getState) => { const { ipcRenderer } = window; return new Promise((resolve, reject) => { - ipcRenderer.invoke('renderer:browse-files', filters).then(resolve).catch(reject); + ipcRenderer.invoke('renderer:browse-files', undefined, undefined, undefined, filters, properties).then(resolve).catch(reject); }); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 11f12026f..35ccf244c 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -18,6 +18,8 @@ import { import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url'; import { getDirectoryName, getSubdirectoriesFromRoot, PATH_SEPARATOR } from 'utils/common/platform'; import toast from 'react-hot-toast'; +import mime from 'mime-types'; +import path from 'node:path'; const initialState = { collections: [], @@ -863,25 +865,89 @@ export const collectionsSlice = createSlice({ } } }, - moveMultipartFormParam: (state, action) => { + moveMultipartFormParam: (state, action) => { + // Ensure item.draft is a deep clone of item if not already present + if (!item.draft) { + item.draft = cloneDeep(item); + } + + // Extract payload data + const { updateReorderedItem } = action.payload; + const params = item.draft.request.body.multipartForm; + + item.draft.request.body.multipartForm = updateReorderedItem.map((uid) => { + return params.find((param) => param.uid === uid); + }); + }, + addBinaryFile: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + + if (collection) { + const item = findItemInCollection(collection, action.payload.itemUid); + + if (item && isItemARequest(item)) { + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.request.body.binaryFile = item.draft.request.body.binaryFile || []; + + item.draft.request.body.binaryFile.push({ + uid: uuid(), + type: action.payload.type, + name: '', + value: [''], + contentType: '', + enabled: false + }); + } + } + }, + updateBinaryFile: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); if (item && isItemARequest(item)) { - // Ensure item.draft is a deep clone of item if not already present if (!item.draft) { item.draft = cloneDeep(item); } - // Extract payload data - const { updateReorderedItem } = action.payload; - const params = item.draft.request.body.multipartForm; - - item.draft.request.body.multipartForm = updateReorderedItem.map((uid) => { - return params.find((param) => param.uid === uid); + item.draft.request.body.binaryFile = item.draft.request.body.binaryFile.map((p) => { + p.enabled = false; + return p; }); + + const param = find(item.draft.request.body.binaryFile, (p) => p.uid === action.payload.param.uid); + + if (param) { + + const contentType = mime.contentType(path.extname(action.payload.param.value[0])); + + param.type = action.payload.param.type; + param.name = action.payload.param.name; + param.value = action.payload.param.value; + param.contentType = action.payload.param.contentType || contentType || ''; + param.enabled = action.payload.param.enabled; + } + } + } + }, + deleteBinaryFile: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + + if (collection) { + const item = findItemInCollection(collection, action.payload.itemUid); + + if (item && isItemARequest(item)) { + if (!item.draft) { + item.draft = cloneDeep(item); + } + + item.draft.request.body.binaryFile = filter( + item.draft.request.body.binaryFile, + (p) => p.uid !== action.payload.paramUid + ); } } }, @@ -941,6 +1007,10 @@ export const collectionsSlice = createSlice({ item.draft.request.body.sparql = action.payload.content; break; } + case 'binaryFile': { + item.draft.request.body.binaryFile = action.payload.content; + break; + } case 'formUrlEncoded': { item.draft.request.body.formUrlEncoded = action.payload.content; break; @@ -1933,6 +2003,9 @@ export const { addMultipartFormParam, updateMultipartFormParam, deleteMultipartFormParam, + addBinaryFile, + updateBinaryFile, + deleteBinaryFile, moveMultipartFormParam, updateRequestAuthMode, updateRequestBodyMode, diff --git a/packages/bruno-app/src/utils/codegenerator/har.js b/packages/bruno-app/src/utils/codegenerator/har.js index 479fcd67a..19e4ea4de 100644 --- a/packages/bruno-app/src/utils/codegenerator/har.js +++ b/packages/bruno-app/src/utils/codegenerator/har.js @@ -14,6 +14,8 @@ const createContentType = (mode) => { return 'application/json'; case 'multipartForm': return 'multipart/form-data'; + case 'binaryFile': + return 'application/octet-stream'; default: return ''; } @@ -60,26 +62,48 @@ const createPostData = (body, type) => { } const contentType = createContentType(body.mode); - if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') { - return { - mimeType: contentType, - params: body[body.mode] - .filter((param) => param.enabled) - .map((param) => ({ - name: param.name, - value: param.value, - ...(param.type === 'file' && { fileName: param.value }) - })) - }; - } else { - return { - mimeType: contentType, - text: body[body.mode] - }; + + switch (body.mode) { + case 'formUrlEncoded': + case 'multipartForm': + return { + mimeType: contentType, + params: body[body.mode] + .filter((param) => param.enabled) + .map((param) => ({ + name: param.name, + value: param.value, + ...(param.type === 'file' && { fileName: param.value }) + })) + }; + case 'binaryFile': + const binary = { + mimeType: 'application/octet-stream', + // mimeType: body[body.mode].filter((param) => param.enabled)[0].contentType, + params: body[body.mode] + .filter((param) => param.enabled) + .map((param) => ({ + name: param.name, + value: param.value, + fileName: param.value + })) + }; + + console.log('curl-binary', binary); + return binary; + default: + return { + mimeType: contentType, + text: body[body.mode] + }; } }; export const buildHarRequest = ({ request, headers, type }) => { + + console.log('buildHarRequest', request, headers, type); + + console.log('buildHarRequest-postData', createPostData(request.body, type)); return { method: request.method, url: encodeURI(request.url), @@ -89,6 +113,7 @@ export const buildHarRequest = ({ request, headers, type }) => { queryString: createQuery(request.params), postData: createPostData(request.body, type), headersSize: 0, - bodySize: 0 + bodySize: 0, + binary: true }; }; diff --git a/packages/bruno-app/src/utils/collections/export.js b/packages/bruno-app/src/utils/collections/export.js index 5ef7b1b49..b7ca9f5ba 100644 --- a/packages/bruno-app/src/utils/collections/export.js +++ b/packages/bruno-app/src/utils/collections/export.js @@ -14,6 +14,7 @@ export const deleteUidsInItems = (items) => { each(get(item, 'request.vars.assertions'), (a) => delete a.uid); each(get(item, 'request.body.multipartForm'), (param) => delete param.uid); each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid); + each(get(item, 'request.body.binaryFile'), (param) => delete param.uid); } if (item.items && item.items.length) { diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index bc6c731f4..96e91b78a 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -271,6 +271,19 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} }); }; + const copyBinaryFileParams = (params = []) => { + return map(params, (param) => { + return { + uid: param.uid, + type: param.type, + name: param.name, + value: param.value, + contentType: param.contentType, + enabled: param.enabled + } + }); + } + const copyItems = (sourceItems, destItems) => { each(sourceItems, (si) => { if (!isItemAFolder(si) && !isItemARequest(si) && si.type !== 'js') { @@ -298,7 +311,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} graphql: si.request.body.graphql, sparql: si.request.body.sparql, formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded), - multipartForm: copyMultipartFormParams(si.request.body.multipartForm) + multipartForm: copyMultipartFormParams(si.request.body.multipartForm), + binaryFile: copyBinaryFileParams(si.request.body.binaryFile) }, script: si.request.script, vars: si.request.vars, @@ -651,6 +665,10 @@ export const humanizeRequestBodyMode = (mode) => { label = 'SPARQL'; break; } + case 'binaryFile': { + label = 'Binary File'; + break; + } case 'formUrlEncoded': { label = 'Form URL Encoded'; break; @@ -751,6 +769,7 @@ export const refreshUidsInItem = (item) => { each(get(item, 'request.params'), (param) => (param.uid = uuid())); each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid())); each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid())); + each(get(item, 'request.body.binaryFile'), (param) => (param.uid = uuid())); return item; }; @@ -761,11 +780,13 @@ export const deleteUidsInItem = (item) => { const headers = get(item, 'request.headers', []); const bodyFormUrlEncoded = get(item, 'request.body.formUrlEncoded', []); const bodyMultipartForm = get(item, 'request.body.multipartForm', []); + const binaryFile = get(item, 'request.body.binaryFile', []); params.forEach((param) => delete param.uid); headers.forEach((header) => delete header.uid); bodyFormUrlEncoded.forEach((param) => delete param.uid); bodyMultipartForm.forEach((param) => delete param.uid); + binaryFile.forEach((param) => delete param.uid); return item; }; diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.js b/packages/bruno-app/src/utils/curl/curl-to-json.js index c1398ab14..5a2c62022 100644 --- a/packages/bruno-app/src/utils/curl/curl-to-json.js +++ b/packages/bruno-app/src/utils/curl/curl-to-json.js @@ -9,6 +9,7 @@ import parseCurlCommand from './parse-curl'; import * as querystring from 'query-string'; import * as jsesc from 'jsesc'; +import * as path from 'path'; function getContentType(headers = {}) { const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type'); @@ -99,9 +100,36 @@ function getMultipleDataString(request, parsedQueryString) { function getFilesString(request) { const data = {}; - data.files = {}; data.data = {}; + if (request.isDataBinary){ + + let filePath = '' + + if(request.data.startsWith('@')){ + filePath = request.data.slice(1); + }else{ + filePath = request.data; + } + + const fileName = path.basename(filePath); + + data.data = [ + { + name: repr(fileName), + value: [repr(filePath)], + enabled: true, + contentType: request.headers['Content-Type'], + type: 'binaryFile' + } + ]; + + return data; + + } + + data.files = {}; + for (const multipartKey in request.multipartUploads) { const multipartValue = request.multipartUploads[multipartKey]; if (multipartValue.startsWith('@')) { @@ -140,6 +168,7 @@ const curlToJson = (curlCommand) => { requestJson.url = request.urlWithoutQuery; requestJson.raw_url = request.url; requestJson.method = request.method; + requestJson.isDataBinary = request.isDataBinary; if (request.cookies) { const cookies = {}; @@ -163,11 +192,11 @@ const curlToJson = (curlCommand) => { requestJson.queries = getQueries(request); } - if (typeof request.data === 'string' || typeof request.data === 'number') { - Object.assign(requestJson, getDataString(request)); - } else if (request.multipartUploads) { + else if (request.multipartUploads || request.isDataBinary) { Object.assign(requestJson, getFilesString(request)); - } + } else if (typeof request.data === 'string' || typeof request.data === 'number') { + Object.assign(requestJson, getDataString(request)); + } if (request.insecure) { requestJson.insecure = false; diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js index 2d9785154..6f8206139 100644 --- a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js +++ b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js @@ -86,4 +86,40 @@ describe('curlToJson', () => { method: 'get' }); }); + + it('should return a parse a curl with a post body with binary file type', () => { + 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-binary '@/path/to/file' + `; + + const result = curlToJson(curlCommand); + + expect(result).toEqual({ + url: 'https://www.usebruno.com', + 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/' + }, + isDataBinary: true, + data: [ + { + name: 'file', + value: ['/path/to/file'], + enabled: true, + contentType: 'application/json;charset=utf-8', + type: 'binaryFile' + } + ] + }); + }); }); diff --git a/packages/bruno-app/src/utils/curl/index.js b/packages/bruno-app/src/utils/curl/index.js index f486df56b..d91588178 100644 --- a/packages/bruno-app/src/utils/curl/index.js +++ b/packages/bruno-app/src/utils/curl/index.js @@ -50,14 +50,18 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque sparql: null, multipartForm: null, formUrlEncoded: null, - graphql: null + graphql: null, + binaryFile: null }; if (parsedBody && contentType && typeof contentType === 'string') { if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) { body.mode = 'graphql'; body.graphql = parseGraphQL(parsedBody); - } else if (contentType.includes('application/json')) { + } else if (requestType === 'http-request' && request.isDataBinary) { + body.mode = 'binaryFile'; + body.binaryFile = parsedBody; + }else if (contentType.includes('application/json')) { body.mode = 'json'; body.json = convertToCodeMirrorJson(parsedBody); } else if (contentType.includes('xml')) { diff --git a/packages/bruno-app/src/utils/importers/common.js b/packages/bruno-app/src/utils/importers/common.js index 88c4c7872..af187cc82 100644 --- a/packages/bruno-app/src/utils/importers/common.js +++ b/packages/bruno-app/src/utils/importers/common.js @@ -35,6 +35,7 @@ export const updateUidsInCollection = (_collection) => { each(get(item, 'request.assertions'), (a) => (a.uid = uuid())); each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid())); each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid())); + each(get(item, 'request.body.binaryFile'), (param) => (param.uid = uuid())); if (item.items && item.items.length) { updateItemUids(item.items); diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index 43d01153d..be98473ad 100644 --- a/packages/bruno-electron/src/app/watcher.js +++ b/packages/bruno-electron/src/app/watcher.js @@ -54,6 +54,7 @@ const hydrateRequestWithUuid = (request, pathname) => { const assertions = _.get(request, 'request.assertions', []); const bodyFormUrlEncoded = _.get(request, 'request.body.formUrlEncoded', []); const bodyMultipartForm = _.get(request, 'request.body.multipartForm', []); + const binaryFile = _.get(request, 'request.body.binaryFile', []); params.forEach((param) => (param.uid = uuid())); headers.forEach((header) => (header.uid = uuid())); @@ -62,6 +63,7 @@ const hydrateRequestWithUuid = (request, pathname) => { assertions.forEach((assertion) => (assertion.uid = uuid())); bodyFormUrlEncoded.forEach((param) => (param.uid = uuid())); bodyMultipartForm.forEach((param) => (param.uid = uuid())); + binaryFile.forEach((param) => (param.uid = uuid())); return request; }; @@ -241,7 +243,6 @@ const add = async (win, pathname, collectionUid, collectionPath) => { // Is this a folder.bru file? if (path.basename(pathname) === 'folder.bru') { - console.log('folder.bru file detected'); const file = { meta: { collectionUid, diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 898324892..c0391f4d6 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -54,9 +54,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection }); // browse directory for file - ipcMain.handle('renderer:browse-files', async (event, pathname, request, filters) => { + ipcMain.handle('renderer:browse-files', async (event, pathname, request, filters, properties) => { try { - const filePaths = await browseFiles(mainWindow, filters); + + const filePaths = await browseFiles(mainWindow, filters, properties); return filePaths; } catch (error) { diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 1865426e0..7495c94fa 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -570,19 +570,21 @@ const registerNetworkIpc = (mainWindow) => { cancelTokenUid }); - const collectionRoot = get(collection, 'root', {}); - const request = prepareRequest(item, collection); - request.__bruno__executionMode = 'standalone'; - const envVars = getEnvVars(environment); - const processEnvVars = getProcessEnvVars(collectionUid); - const brunoConfig = getBrunoConfig(collectionUid); - const scriptingConfig = get(brunoConfig, 'scripts', {}); - scriptingConfig.runtime = getJsSandboxRuntime(collection); + const abortController = new AbortController(); + + const collectionRoot = get(collection, 'root', {}); + const request = await prepareRequest(item, collection, abortController); + request.__bruno__executionMode = 'standalone'; + const envVars = getEnvVars(environment); + const processEnvVars = getProcessEnvVars(collectionUid); + const brunoConfig = getBrunoConfig(collectionUid); + const scriptingConfig = get(brunoConfig, 'scripts', {}); + scriptingConfig.runtime = getJsSandboxRuntime(collection); try { - const controller = new AbortController(); - request.signal = controller.signal; - saveCancelToken(cancelTokenUid, controller); + request.signal = abortController.signal; + + saveCancelToken(cancelTokenUid, abortController); await runPreRequest( request, @@ -612,7 +614,7 @@ const registerNetworkIpc = (mainWindow) => { url: request.url, method: request.method, headers: request.headers, - data: safeParseJSON(safeStringifyJSON(request.data)), + data: request.mode == 'binaryFile'? undefined: safeParseJSON(safeStringifyJSON(request.data)) , timestamp: Date.now() }, collectionUid, @@ -1031,7 +1033,7 @@ const registerNetworkIpc = (mainWindow) => { ...eventData }); - const request = prepareRequest(item, collection); + const request = await prepareRequest(item, collection, abortController); request.__bruno__executionMode = 'runner'; const requestUid = uuid(); diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index 6c7672e7d..297033c6d 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -1,8 +1,10 @@ const { get, each, filter } = require('lodash'); const decomment = require('decomment'); const crypto = require('node:crypto'); +const fs = require('node:fs/promises'); const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars } = require('../../utils/collection'); const { buildFormUrlEncodedPayload, createFormData } = require('../../utils/form-data'); +const path = require('node:path'); const setAuthHeaders = (axiosRequest, request, collectionRoot) => { @@ -174,7 +176,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { return axiosRequest; }; -const prepareRequest = (item, collection) => { +const prepareRequest = async (item, collection, abortController) => { const request = item.draft ? item.draft.request : item.request; const collectionRoot = get(collection, 'root', {}); const collectionPath = collection.pathname; @@ -251,6 +253,36 @@ const prepareRequest = (item, collection) => { axiosRequest.data = request.body.sparql; } + if (request.body.mode === 'binaryFile') { + + if (!contentTypeDefined) { + axiosRequest.headers['content-type'] = 'application/octet-stream'; + } + + if (request.body.binaryFile && request.body.binaryFile.length > 0) { + + axiosRequest.headers['content-type'] = request.body.binaryFile[0].contentType; + + let filePath = request.body.binaryFile[0].value[0]; + + if (filePath && filePath !== '') { + + if (!path.isAbsolute(filePath)) { + + filePath = path.join(collectionPath, filePath); + } + + const file = await fs.readFile(filePath, abortController) + + axiosRequest.data = file + + if(axiosRequest.headers['content-type'].includes('application/json')) { + axiosRequest.data = JSON.parse(file) + } + } + } + } + if (request.body.mode === 'formUrlEncoded') { if (!contentTypeDefined) { axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded'; diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index d2f74d10e..671b628d5 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -121,9 +121,9 @@ const browseDirectory = async (win) => { return isDirectory(resolvedPath) ? resolvedPath : false; }; -const browseFiles = async (win, filters) => { +const browseFiles = async (win, filters, properties) => { const { filePaths } = await dialog.showOpenDialog(win, { - properties: ['openFile', 'multiSelections'], + properties: ['openFile', ...properties], filters }); diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 2fe5fb472..e7b0c5772 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -25,7 +25,7 @@ const grammar = ohm.grammar(`Bru { BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | docs)* auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth2 | authwsse | authapikey bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body - bodyforms = bodyformurlencoded | bodymultipart + bodyforms = bodyformurlencoded | bodymultipart | bodybinaryfile params = paramspath | paramsquery nl = "\\r"? "\\n" @@ -102,7 +102,8 @@ const grammar = ohm.grammar(`Bru { bodyformurlencoded = "body:form-urlencoded" dictionary bodymultipart = "body:multipart-form" dictionary - + bodybinaryfile = "body:binary-file" dictionary + script = scriptreq | scriptres scriptreq = "script:pre-request" st* "{" nl* textblock tagend scriptres = "script:post-response" st* "{" nl* textblock tagend @@ -173,6 +174,19 @@ const multipartExtractContentType = (pair) => { } }; +const binaryFileExtractContentType = (pair) => { + if (_.isString(pair.value)) { + const match = pair.value.match(/^(.*?)\s*@contentType\((.*?)\)\s*$/); + if (match != null && match.length > 2) { + pair.value = match[1]; + pair.contentType = match[2]; + } else { + pair.contentType = ''; + } + } +}; + + const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => { const pairs = mapPairListToKeyValPairs(pairList, parseEnabled); @@ -190,6 +204,23 @@ const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) = }); }; +const mapPairListToKeyValPairsBinaryFile = (pairList = [], parseEnabled = true) => { + const pairs = mapPairListToKeyValPairs(pairList, parseEnabled); + + return pairs.map((pair) => { + binaryFileExtractContentType(pair); + + if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) { + let filestr = pair.value.replace(/^@file\(/, '').replace(/\)$/, ''); + pair.type = 'binaryFile'; + pair.value = filestr != '' ? filestr.split('|') : ['']; + } + + return pair; + }); +}; + + const concatArrays = (objValue, srcValue) => { if (_.isArray(objValue) && _.isArray(srcValue)) { return objValue.concat(srcValue); @@ -574,6 +605,13 @@ const sem = grammar.createSemantics().addAttribute('ast', { } }; }, + bodybinaryfile(_1, dictionary) { + return { + body: { + binaryFile: mapPairListToKeyValPairsBinaryFile(dictionary.ast) + } + }; + }, body(_1, _2, _3, _4, textblock, _5) { return { http: { diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 62b31c2f9..c4e2ba323 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -313,6 +313,32 @@ ${indentString(body.sparql)} bru += '\n}\n\n'; } + + if (body && body.binaryFile && body.binaryFile.length) { + bru += `body:binary-file {`; + const binaryFiles = enabled(body.binaryFile).concat(disabled(body.binaryFile)); + + if (binaryFiles.length) { + bru += `\n${indentString( + binaryFiles + .map((item) => { + const enabled = item.enabled ? '' : '~'; + const contentType = + item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : ''; + + if (item.type === 'binaryFile') { + let filestr = item.value[0] || ''; + const value = `@file(${filestr})`; + return `${enabled}${item.name}: ${value}${contentType}`; + } + }) + .join('\n') + )}`; + } + + bru += '\n}\n\n'; + } + if (body && body.graphql && body.graphql.query) { bru += `body:graphql {\n`; bru += `${indentString(body.graphql.query)}`; diff --git a/packages/bruno-lang/v2/tests/fixtures/request.bru b/packages/bruno-lang/v2/tests/fixtures/request.bru index 1a3efeab7..5f7183f34 100644 --- a/packages/bruno-lang/v2/tests/fixtures/request.bru +++ b/packages/bruno-lang/v2/tests/fixtures/request.bru @@ -102,6 +102,11 @@ body:multipart-form { ~message: hello } +body:binary-file { + file: @file(path/to/file.json) @contentType(application/json) + ~file2: @file(path/to/file2.json) @contentType(application/json) +} + body:graphql { { launchesPast { diff --git a/packages/bruno-lang/v2/tests/fixtures/request.json b/packages/bruno-lang/v2/tests/fixtures/request.json index 9c8ed143d..ad7a45495 100644 --- a/packages/bruno-lang/v2/tests/fixtures/request.json +++ b/packages/bruno-lang/v2/tests/fixtures/request.json @@ -137,6 +137,22 @@ "enabled": false, "type": "text" } + ], + "binaryFile" : [ + { + "name": "file", + "value": ["path/to/file.json"], + "enabled": true, + "type": "binaryFile", + "contentType": "application/json" + }, + { + "name": "file2", + "value": ["path/to/file2.json"], + "enabled": false, + "type": "binaryFile", + "contentType": "application/json" + } ] }, "vars": { diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index b6e044ae4..19d98afbb 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -74,9 +74,21 @@ const multipartFormSchema = Yup.object({ .noUnknown(true) .strict(); + +const binaryFileSchema = Yup.object({ + uid: uidSchema, + type: Yup.string().oneOf(['binaryFile']).required('type is required'), + name: Yup.string().nullable(), + value: Yup.array().of(Yup.string().nullable()).nullable(), + contentType: Yup.string().nullable(), + enabled: Yup.boolean() +}) + .noUnknown(true) + .strict(); + const requestBodySchema = Yup.object({ mode: Yup.string() - .oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql']) + .oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql', 'binaryFile']) .required('mode is required'), json: Yup.string().nullable(), text: Yup.string().nullable(), @@ -84,7 +96,8 @@ const requestBodySchema = Yup.object({ sparql: Yup.string().nullable(), formUrlEncoded: Yup.array().of(keyValueSchema).nullable(), multipartForm: Yup.array().of(multipartFormSchema).nullable(), - graphql: graphqlBodySchema.nullable() + graphql: graphqlBodySchema.nullable(), + binaryFile: Yup.array().of(binaryFileSchema).nullable() }) .noUnknown(true) .strict(); diff --git a/packages/bruno-tests/collection/binaryFile/binary-file-types.bru b/packages/bruno-tests/collection/binaryFile/binary-file-types.bru new file mode 100644 index 000000000..93275971f --- /dev/null +++ b/packages/bruno-tests/collection/binaryFile/binary-file-types.bru @@ -0,0 +1,27 @@ +meta { + name: binary-files-types + type: http + seq: 1 +} + +post { + url: {{host}}/api/binaryFile/binary-file-types + body: binaryFile + auth: none +} + +body:binary-file { + file1: @file() @contentType() + file2: @file(binaryFile/binary-file.json) @contentType() + file3: @file(binaryFile/binary-file.json) @contentType(application/json) +} + +assert { + res.status: eq 200 + res.body.find(p=>p.name === 'file1').value[0]: isUndefined + res.body.find(p=>p.name === 'file1').contentType: isUndefined + res.body.find(p=>p.name === 'file2').value[0]: eq binaryFile/binary-file.json + res.body.find(p=>p.name === 'file2').contentType: eq isUndefined + res.body.find(p=>p.name === 'file3').value[0]: eq binaryFile/binary-file.json + res.body.find(p=>p.name === 'file3').contentType: eq application/json +} diff --git a/packages/bruno-tests/collection/binaryFile/binary-file.json b/packages/bruno-tests/collection/binaryFile/binary-file.json new file mode 100644 index 000000000..2ff269bff --- /dev/null +++ b/packages/bruno-tests/collection/binaryFile/binary-file.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "bruno-testing", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file