mirror of
https://github.com/usebruno/bruno.git
synced 2024-11-21 23:43:15 +01:00
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:
parent
a97adbb97e
commit
634f9ca4a2
66
packages/bruno-app/src/components/FilePickerEditor/index.js
Normal file
66
packages/bruno-app/src/components/FilePickerEditor/index.js
Normal 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>
|
||||
|
||||
{renderButtonText(filnames)}
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-secondary px-1" style={{ width: '100%' }} onClick={browse}>
|
||||
Select Files
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePickerEditor;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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') {
|
||||
|
@ -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);
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
};
|
||||
},
|
||||
|
@ -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')
|
||||
)}`;
|
||||
}
|
||||
|
@ -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(),
|
||||
|
Loading…
Reference in New Issue
Block a user