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 <mateogallardo@gmail.com>
This commit is contained in:
Max Destors 2024-02-04 18:34:18 +01:00 committed by GitHub
parent a97adbb97e
commit 634f9ca4a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 220 additions and 45 deletions

View File

@ -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;

View File

@ -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 }) => {
/>
</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">
@ -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>
);
};

View File

@ -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();

View File

@ -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;

View File

@ -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') {

View File

@ -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);

View File

@ -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',

View File

@ -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);

View File

@ -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;
}

View File

@ -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,

View File

@ -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)
}
};
},

View File

@ -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')
)}`;
}

View File

@ -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(),