Skip to content

Commit

Permalink
feat: Multipart Form Data file uploads (#1130)
Browse files Browse the repository at this point in the history
* 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 <mateogallardo@gmail.com>
  • Loading branch information
maxdestors committed Feb 4, 2024
1 parent a97adbb commit 634f9ca
Show file tree
Hide file tree
Showing 13 changed files with 220 additions and 45 deletions.
66 changes: 66 additions & 0 deletions packages/bruno-app/src/components/FilePickerEditor/index.js
Original file line number Diff line number Diff line change
@@ -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 ? (
<div
className="btn btn-secondary px-1"
style={{ fontWeight: 400, width: '100%', textOverflow: 'ellipsis', overflowX: 'hidden' }}
title={title}
>
<button className="align-middle" onClick={clear}>
<IconX size={18} />
</button>
&nbsp;
{renderButtonText(filnames)}
</div>
) : (
<button className="btn btn-secondary px-1" style={{ width: '100%' }} onClick={browse}>
Select Files
</button>
);
};

export default FilePickerEditor;
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) => {
Expand Down Expand Up @@ -92,24 +103,42 @@ const MultipartFormParams = ({ item, collection }) => {
/>
</td>
<td>
<SingleLineEditor
onSave={onSave}
theme={storedTheme}
value={param.value}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)
}
onRun={handleRun}
collection={collection}
/>
{param.isFile === true ? (
<FilePickerEditor
value={param.value}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)
}
collection={collection}
/>
) : (
<SingleLineEditor
onSave={onSave}
theme={storedTheme}
value={param.value}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)
}
onRun={handleRun}
collection={collection}
/>
)}
</td>
<td>
<div className="flex items-center">
Expand All @@ -131,9 +160,16 @@ const MultipartFormParams = ({ item, collection }) => {
: null}
</tbody>
</table>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={addParam}>
+ Add Param
</button>
<div>
<button className="btn-add-param text-link pr-2 pt-3 mt-2 select-none" onClick={addParam}>
+ Add Param
</button>
</div>
<div>
<button className="btn-add-param text-link pr-2 pt-3 select-none" onClick={addFile}>
+ Add File
</button>
</div>
</StyledWrapper>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/bruno-cli/src/runner/prepare-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
1 change: 1 addition & 0 deletions packages/bruno-cli/src/runner/run-single-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions packages/bruno-electron/src/ipc/collection.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
hasBruExtension,
isDirectory,
browseDirectory,
browseFiles,
createDirectory,
searchForBruFiles,
sanitizeDirectoryName
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions packages/bruno-electron/src/ipc/network/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
43 changes: 32 additions & 11 deletions packages/bruno-electron/src/ipc/network/prepare-request.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
14 changes: 14 additions & 0 deletions packages/bruno-electron/src/utils/filesystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -147,6 +160,7 @@ module.exports = {
hasBruExtension,
createDirectory,
browseDirectory,
browseFiles,
chooseFileToSave,
searchForFiles,
searchForBruFiles,
Expand Down
14 changes: 13 additions & 1 deletion packages/bruno-lang/v2/src/bruToJson.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -376,7 +388,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
bodymultipart(_1, dictionary) {
return {
body: {
multipartForm: mapPairListToKeyValPairs(dictionary.ast)
multipartForm: mapPairListToKeyValPairsMultipart(dictionary.ast)
}
};
},
Expand Down

0 comments on commit 634f9ca

Please sign in to comment.