From 634f9ca4a279ce0158d1caa1dd3efad8efacdac4 Mon Sep 17 00:00:00 2001 From: Max Destors Date: Sun, 4 Feb 2024 18:34:18 +0100 Subject: [PATCH] feat: Multipart Form Data file uploads (#1130) * Add multipart form files upload support * clean up * Fixed electron files browser for Multipart Form files * Using relative paths for files inside the collection's folder --------- Co-authored-by: Mateo Gallardo --- .../src/components/FilePickerEditor/index.js | 66 ++++++++++++++++ .../RequestPane/MultipartFormParams/index.js | 78 ++++++++++++++----- .../ReduxStore/slices/collections/actions.js | 10 +++ .../ReduxStore/slices/collections/index.js | 2 + .../bruno-cli/src/runner/prepare-request.js | 1 + .../src/runner/run-single-request.js | 1 + packages/bruno-electron/src/ipc/collection.js | 12 +++ .../bruno-electron/src/ipc/network/index.js | 4 +- .../src/ipc/network/prepare-request.js | 43 +++++++--- .../bruno-electron/src/utils/filesystem.js | 14 ++++ packages/bruno-lang/v2/src/bruToJson.js | 14 +++- packages/bruno-lang/v2/src/jsonToBru.js | 19 +++-- .../bruno-schema/src/collections/index.js | 1 + 13 files changed, 220 insertions(+), 45 deletions(-) create mode 100644 packages/bruno-app/src/components/FilePickerEditor/index.js diff --git a/packages/bruno-app/src/components/FilePickerEditor/index.js b/packages/bruno-app/src/components/FilePickerEditor/index.js new file mode 100644 index 000000000..a8b33c653 --- /dev/null +++ b/packages/bruno-app/src/components/FilePickerEditor/index.js @@ -0,0 +1,66 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; +import { IconX } from '@tabler/icons'; + +const FilePickerEditor = ({ value, onChange, collection }) => { + const dispatch = useDispatch(); + const filnames = value + .split('|') + .filter((v) => v != null && v != '') + .map((v) => v.split('\\').pop()); + const title = filnames.map((v) => `- ${v}`).join('\n'); + + const browse = () => { + dispatch(browseFiles()) + .then((filePaths) => { + // If file is in the collection's directory, then we use relative path + // Otherwise, we use the absolute path + filePaths = filePaths.map((filePath) => { + const collectionDir = collection.pathname; + + if (filePath.startsWith(collectionDir)) { + return filePath.substring(collectionDir.length + 1); + } + + return filePath; + }); + + onChange(filePaths.join('|')); + }) + .catch((error) => { + console.error(error); + }); + }; + + const clear = () => { + onChange(''); + }; + + const renderButtonText = (filnames) => { + if (filnames.length == 1) { + return filnames[0]; + } + return filnames.length + ' files selected'; + }; + + return filnames.length > 0 ? ( +
+ +   + {renderButtonText(filnames)} +
+ ) : ( + + ); +}; + +export default FilePickerEditor; diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js index f8bd7ebbd..13464c6c9 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js @@ -12,6 +12,7 @@ import { import SingleLineEditor from 'components/SingleLineEditor'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; +import FilePickerEditor from 'components/FilePickerEditor/index'; const MultipartFormParams = ({ item, collection }) => { const dispatch = useDispatch(); @@ -27,6 +28,16 @@ const MultipartFormParams = ({ item, collection }) => { ); }; + const addFile = () => { + dispatch( + addMultipartFormParam({ + itemUid: item.uid, + collectionUid: collection.uid, + isFile: true + }) + ); + }; + const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); const handleRun = () => dispatch(sendRequest(item, collection.uid)); const handleParamChange = (e, _param, type) => { @@ -92,24 +103,42 @@ const MultipartFormParams = ({ item, collection }) => { /> - - handleParamChange( - { - target: { - value: newValue - } - }, - param, - 'value' - ) - } - onRun={handleRun} - collection={collection} - /> + {param.isFile === true ? ( + + handleParamChange( + { + target: { + value: newValue + } + }, + param, + 'value' + ) + } + collection={collection} + /> + ) : ( + + handleParamChange( + { + target: { + value: newValue + } + }, + param, + 'value' + ) + } + onRun={handleRun} + collection={collection} + /> + )}
@@ -131,9 +160,16 @@ const MultipartFormParams = ({ item, collection }) => { : null} - +
+ +
+
+ +
); }; 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 e8d7093eb..cdf29e4c8 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -909,6 +909,16 @@ export const browseDirectory = () => (dispatch, getState) => { }); }; +export const browseFiles = + (filters = []) => + (dispatch, getState) => { + const { ipcRenderer } = window; + + return new Promise((resolve, reject) => { + ipcRenderer.invoke('renderer:browse-files', filters).then(resolve).catch(reject); + }); + }; + export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getState) => { const state = getState(); 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 09bf07061..18102ab07 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -617,6 +617,7 @@ export const collectionsSlice = createSlice({ item.draft.request.body.multipartForm = item.draft.request.body.multipartForm || []; item.draft.request.body.multipartForm.push({ uid: uuid(), + isFile: action.payload.isFile ?? false, name: '', value: '', description: '', @@ -637,6 +638,7 @@ export const collectionsSlice = createSlice({ } const param = find(item.draft.request.body.multipartForm, (p) => p.uid === action.payload.param.uid); if (param) { + param.isFile = action.payload.param.isFile; param.name = action.payload.param.name; param.value = action.payload.param.value; param.description = action.payload.param.description; diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index ace3b3101..957013b11 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -109,6 +109,7 @@ const prepareRequest = (request, collectionRoot) => { each(enabledParams, (p) => (params[p.name] = p.value)); axiosRequest.headers['content-type'] = 'multipart/form-data'; axiosRequest.data = params; + // TODO is it needed here as well ? } if (request.body.mode === 'graphql') { diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 3a94daa44..0390a99d2 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -40,6 +40,7 @@ const runSingleRequest = async function ( // make axios work in node using form data // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427 if (request.headers && request.headers['content-type'] === 'multipart/form-data') { + // TODO remove ? const form = new FormData(); forOwn(request.data, (value, key) => { form.append(key, value); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index e6a1c2c37..3b53b9839 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -10,6 +10,7 @@ const { hasBruExtension, isDirectory, browseDirectory, + browseFiles, createDirectory, searchForBruFiles, sanitizeDirectoryName @@ -38,6 +39,17 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } }); + // browse directory for file + ipcMain.handle('renderer:browse-files', async (event, pathname, request, filters) => { + try { + const filePaths = await browseFiles(mainWindow, filters); + + return filePaths; + } catch (error) { + return Promise.reject(error); + } + }); + // create collection ipcMain.handle( 'renderer:create-collection', diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 2cd076732..89d198457 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -393,7 +393,7 @@ const registerNetworkIpc = (mainWindow) => { const collectionRoot = get(collection, 'root', {}); const _request = item.draft ? item.draft.request : item.request; - const request = prepareRequest(_request, collectionRoot); + const request = prepareRequest(_request, collectionRoot, collectionPath); const envVars = getEnvVars(environment); const processEnvVars = getProcessEnvVars(collectionUid); const brunoConfig = getBrunoConfig(collectionUid); @@ -735,7 +735,7 @@ const registerNetworkIpc = (mainWindow) => { }); const _request = item.draft ? item.draft.request : item.request; - const request = prepareRequest(_request, collectionRoot); + const request = prepareRequest(_request, collectionRoot, collectionPath); const requestUid = uuid(); const processEnvVars = getProcessEnvVars(collectionUid); diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index 761984e65..9d90efb6a 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -1,6 +1,35 @@ const { get, each, filter, forOwn, extend } = require('lodash'); const decomment = require('decomment'); const FormData = require('form-data'); +const fs = require('fs'); +const path = require('path'); + +const parseFormData = (datas, collectionPath) => { + const form = new FormData(); + datas.forEach((item) => { + const value = item.value; + const name = item.name; + if (item.isFile === true) { + const filePaths = value + .toString() + .replace(/^@file\(/, '') + .replace(/\)$/, '') + .split('|'); + + filePaths.forEach((filePath) => { + let trimmedFilePath = filePath.trim(); + if (!path.isAbsolute(trimmedFilePath)) { + trimmedFilePath = path.join(collectionPath, trimmedFilePath); + } + const file = fs.readFileSync(trimmedFilePath); + form.append(name, file, path.basename(trimmedFilePath)); + }); + } else { + form.append(name, value); + } + }); + return form; +}; // Authentication // A request can override the collection auth with another auth @@ -70,7 +99,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { return axiosRequest; }; -const prepareRequest = (request, collectionRoot) => { +const prepareRequest = (request, collectionRoot, collectionPath) => { const headers = {}; let contentTypeDefined = false; let url = request.url; @@ -146,18 +175,10 @@ const prepareRequest = (request, collectionRoot) => { } if (request.body.mode === 'multipartForm') { - const params = {}; - const enabledParams = filter(request.body.multipartForm, (p) => p.enabled); - each(enabledParams, (p) => (params[p.name] = p.value)); - axiosRequest.headers['content-type'] = 'multipart/form-data'; - axiosRequest.data = params; - // make axios work in node using form data // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427 - const form = new FormData(); - forOwn(axiosRequest.data, (value, key) => { - form.append(key, value); - }); + const enabledParams = filter(request.body.multipartForm, (p) => p.enabled); + const form = parseFormData(enabledParams, collectionPath); extend(axiosRequest.headers, form.getHeaders()); axiosRequest.data = form; } diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index 4f3ea980b..8216bd9c9 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -103,6 +103,19 @@ const browseDirectory = async (win) => { return isDirectory(resolvedPath) ? resolvedPath : false; }; +const browseFiles = async (win, filters) => { + const { filePaths } = await dialog.showOpenDialog(win, { + properties: ['openFile', 'multiSelections'], + filters + }); + + if (!filePaths) { + return []; + } + + return filePaths.map((path) => normalizeAndResolvePath(path)).filter((path) => isFile(path)); +}; + const chooseFileToSave = async (win, preferredFileName = '') => { const { filePath } = await dialog.showSaveDialog(win, { defaultPath: preferredFileName @@ -147,6 +160,7 @@ module.exports = { hasBruExtension, createDirectory, browseDirectory, + browseFiles, chooseFileToSave, searchForFiles, searchForBruFiles, diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index fbe289974..ddb54743b 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -128,6 +128,18 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => { }); }; +const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => { + const pairs = mapPairListToKeyValPairs(pairList, parseEnabled); + + return pairs.map((pair) => { + if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) { + pair.isFile = true; + pair.value = pair.value.replace(/^@file\(/, '').replace(/\)$/, ''); + } + return pair; + }); +}; + const concatArrays = (objValue, srcValue) => { if (_.isArray(objValue) && _.isArray(srcValue)) { return objValue.concat(srcValue); @@ -376,7 +388,7 @@ const sem = grammar.createSemantics().addAttribute('ast', { bodymultipart(_1, dictionary) { return { body: { - multipartForm: mapPairListToKeyValPairs(dictionary.ast) + multipartForm: mapPairListToKeyValPairsMultipart(dictionary.ast) } }; }, diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index f4959500a..d94a0e8f4 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -181,18 +181,17 @@ ${indentString(body.sparql)} if (body && body.multipartForm && body.multipartForm.length) { bru += `body:multipart-form {`; - if (enabled(body.multipartForm).length) { - bru += `\n${indentString( - enabled(body.multipartForm) - .map((item) => `${item.name}: ${item.value}`) - .join('\n') - )}`; - } + const multipartForms = enabled(body.multipartForm).concat(disabled(body.multipartForm)); - if (disabled(body.multipartForm).length) { + if (multipartForms.length) { bru += `\n${indentString( - disabled(body.multipartForm) - .map((item) => `~${item.name}: ${item.value}`) + multipartForms + .map((item) => { + const enabled = item.enabled ? '' : '~'; + const value = item.isFile ? `@file(${item.value})` : item.value; + + return `${enabled}${item.name}: ${value}`; + }) .join('\n') )}`; } diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 37e6629af..10db08767 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -24,6 +24,7 @@ const environmentsSchema = Yup.array().of(environmentSchema); const keyValueSchema = Yup.object({ uid: uidSchema, + isFile: Yup.boolean().nullable(), name: Yup.string().nullable(), value: Yup.string().nullable(), description: Yup.string().nullable(),