mirror of
https://github.com/usebruno/bruno.git
synced 2025-06-24 22:11:38 +02: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 SingleLineEditor from 'components/SingleLineEditor';
|
||||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||||
import StyledWrapper from './StyledWrapper';
|
import StyledWrapper from './StyledWrapper';
|
||||||
|
import FilePickerEditor from 'components/FilePickerEditor/index';
|
||||||
|
|
||||||
const MultipartFormParams = ({ item, collection }) => {
|
const MultipartFormParams = ({ item, collection }) => {
|
||||||
const dispatch = useDispatch();
|
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 onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||||
const handleParamChange = (e, _param, type) => {
|
const handleParamChange = (e, _param, type) => {
|
||||||
@ -92,24 +103,42 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<SingleLineEditor
|
{param.isFile === true ? (
|
||||||
onSave={onSave}
|
<FilePickerEditor
|
||||||
theme={storedTheme}
|
value={param.value}
|
||||||
value={param.value}
|
onChange={(newValue) =>
|
||||||
onChange={(newValue) =>
|
handleParamChange(
|
||||||
handleParamChange(
|
{
|
||||||
{
|
target: {
|
||||||
target: {
|
value: newValue
|
||||||
value: newValue
|
}
|
||||||
}
|
},
|
||||||
},
|
param,
|
||||||
param,
|
'value'
|
||||||
'value'
|
)
|
||||||
)
|
}
|
||||||
}
|
collection={collection}
|
||||||
onRun={handleRun}
|
/>
|
||||||
collection={collection}
|
) : (
|
||||||
/>
|
<SingleLineEditor
|
||||||
|
onSave={onSave}
|
||||||
|
theme={storedTheme}
|
||||||
|
value={param.value}
|
||||||
|
onChange={(newValue) =>
|
||||||
|
handleParamChange(
|
||||||
|
{
|
||||||
|
target: {
|
||||||
|
value: newValue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
param,
|
||||||
|
'value'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRun={handleRun}
|
||||||
|
collection={collection}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -131,9 +160,16 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
: null}
|
: null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={addParam}>
|
<div>
|
||||||
+ Add Param
|
<button className="btn-add-param text-link pr-2 pt-3 mt-2 select-none" onClick={addParam}>
|
||||||
</button>
|
+ 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>
|
</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) => {
|
export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getState) => {
|
||||||
const state = 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 = item.draft.request.body.multipartForm || [];
|
||||||
item.draft.request.body.multipartForm.push({
|
item.draft.request.body.multipartForm.push({
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
|
isFile: action.payload.isFile ?? false,
|
||||||
name: '',
|
name: '',
|
||||||
value: '',
|
value: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -637,6 +638,7 @@ export const collectionsSlice = createSlice({
|
|||||||
}
|
}
|
||||||
const param = find(item.draft.request.body.multipartForm, (p) => p.uid === action.payload.param.uid);
|
const param = find(item.draft.request.body.multipartForm, (p) => p.uid === action.payload.param.uid);
|
||||||
if (param) {
|
if (param) {
|
||||||
|
param.isFile = action.payload.param.isFile;
|
||||||
param.name = action.payload.param.name;
|
param.name = action.payload.param.name;
|
||||||
param.value = action.payload.param.value;
|
param.value = action.payload.param.value;
|
||||||
param.description = action.payload.param.description;
|
param.description = action.payload.param.description;
|
||||||
|
@ -109,6 +109,7 @@ const prepareRequest = (request, collectionRoot) => {
|
|||||||
each(enabledParams, (p) => (params[p.name] = p.value));
|
each(enabledParams, (p) => (params[p.name] = p.value));
|
||||||
axiosRequest.headers['content-type'] = 'multipart/form-data';
|
axiosRequest.headers['content-type'] = 'multipart/form-data';
|
||||||
axiosRequest.data = params;
|
axiosRequest.data = params;
|
||||||
|
// TODO is it needed here as well ?
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.body.mode === 'graphql') {
|
if (request.body.mode === 'graphql') {
|
||||||
|
@ -40,6 +40,7 @@ const runSingleRequest = async function (
|
|||||||
// make axios work in node using form data
|
// make axios work in node using form data
|
||||||
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
|
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
|
||||||
if (request.headers && request.headers['content-type'] === 'multipart/form-data') {
|
if (request.headers && request.headers['content-type'] === 'multipart/form-data') {
|
||||||
|
// TODO remove ?
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
forOwn(request.data, (value, key) => {
|
forOwn(request.data, (value, key) => {
|
||||||
form.append(key, value);
|
form.append(key, value);
|
||||||
|
@ -10,6 +10,7 @@ const {
|
|||||||
hasBruExtension,
|
hasBruExtension,
|
||||||
isDirectory,
|
isDirectory,
|
||||||
browseDirectory,
|
browseDirectory,
|
||||||
|
browseFiles,
|
||||||
createDirectory,
|
createDirectory,
|
||||||
searchForBruFiles,
|
searchForBruFiles,
|
||||||
sanitizeDirectoryName
|
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
|
// create collection
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'renderer:create-collection',
|
'renderer:create-collection',
|
||||||
|
@ -393,7 +393,7 @@ const registerNetworkIpc = (mainWindow) => {
|
|||||||
|
|
||||||
const collectionRoot = get(collection, 'root', {});
|
const collectionRoot = get(collection, 'root', {});
|
||||||
const _request = item.draft ? item.draft.request : item.request;
|
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 envVars = getEnvVars(environment);
|
||||||
const processEnvVars = getProcessEnvVars(collectionUid);
|
const processEnvVars = getProcessEnvVars(collectionUid);
|
||||||
const brunoConfig = getBrunoConfig(collectionUid);
|
const brunoConfig = getBrunoConfig(collectionUid);
|
||||||
@ -735,7 +735,7 @@ const registerNetworkIpc = (mainWindow) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const _request = item.draft ? item.draft.request : item.request;
|
const _request = item.draft ? item.draft.request : item.request;
|
||||||
const request = prepareRequest(_request, collectionRoot);
|
const request = prepareRequest(_request, collectionRoot, collectionPath);
|
||||||
const requestUid = uuid();
|
const requestUid = uuid();
|
||||||
const processEnvVars = getProcessEnvVars(collectionUid);
|
const processEnvVars = getProcessEnvVars(collectionUid);
|
||||||
|
|
||||||
|
@ -1,6 +1,35 @@
|
|||||||
const { get, each, filter, forOwn, extend } = require('lodash');
|
const { get, each, filter, forOwn, extend } = require('lodash');
|
||||||
const decomment = require('decomment');
|
const decomment = require('decomment');
|
||||||
const FormData = require('form-data');
|
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
|
// Authentication
|
||||||
// A request can override the collection auth with another auth
|
// A request can override the collection auth with another auth
|
||||||
@ -70,7 +99,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
|||||||
return axiosRequest;
|
return axiosRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
const prepareRequest = (request, collectionRoot) => {
|
const prepareRequest = (request, collectionRoot, collectionPath) => {
|
||||||
const headers = {};
|
const headers = {};
|
||||||
let contentTypeDefined = false;
|
let contentTypeDefined = false;
|
||||||
let url = request.url;
|
let url = request.url;
|
||||||
@ -146,18 +175,10 @@ const prepareRequest = (request, collectionRoot) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.body.mode === 'multipartForm') {
|
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
|
// make axios work in node using form data
|
||||||
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
|
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
|
||||||
const form = new FormData();
|
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
|
||||||
forOwn(axiosRequest.data, (value, key) => {
|
const form = parseFormData(enabledParams, collectionPath);
|
||||||
form.append(key, value);
|
|
||||||
});
|
|
||||||
extend(axiosRequest.headers, form.getHeaders());
|
extend(axiosRequest.headers, form.getHeaders());
|
||||||
axiosRequest.data = form;
|
axiosRequest.data = form;
|
||||||
}
|
}
|
||||||
|
@ -103,6 +103,19 @@ const browseDirectory = async (win) => {
|
|||||||
return isDirectory(resolvedPath) ? resolvedPath : false;
|
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 chooseFileToSave = async (win, preferredFileName = '') => {
|
||||||
const { filePath } = await dialog.showSaveDialog(win, {
|
const { filePath } = await dialog.showSaveDialog(win, {
|
||||||
defaultPath: preferredFileName
|
defaultPath: preferredFileName
|
||||||
@ -147,6 +160,7 @@ module.exports = {
|
|||||||
hasBruExtension,
|
hasBruExtension,
|
||||||
createDirectory,
|
createDirectory,
|
||||||
browseDirectory,
|
browseDirectory,
|
||||||
|
browseFiles,
|
||||||
chooseFileToSave,
|
chooseFileToSave,
|
||||||
searchForFiles,
|
searchForFiles,
|
||||||
searchForBruFiles,
|
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) => {
|
const concatArrays = (objValue, srcValue) => {
|
||||||
if (_.isArray(objValue) && _.isArray(srcValue)) {
|
if (_.isArray(objValue) && _.isArray(srcValue)) {
|
||||||
return objValue.concat(srcValue);
|
return objValue.concat(srcValue);
|
||||||
@ -376,7 +388,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
|||||||
bodymultipart(_1, dictionary) {
|
bodymultipart(_1, dictionary) {
|
||||||
return {
|
return {
|
||||||
body: {
|
body: {
|
||||||
multipartForm: mapPairListToKeyValPairs(dictionary.ast)
|
multipartForm: mapPairListToKeyValPairsMultipart(dictionary.ast)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -181,18 +181,17 @@ ${indentString(body.sparql)}
|
|||||||
|
|
||||||
if (body && body.multipartForm && body.multipartForm.length) {
|
if (body && body.multipartForm && body.multipartForm.length) {
|
||||||
bru += `body:multipart-form {`;
|
bru += `body:multipart-form {`;
|
||||||
if (enabled(body.multipartForm).length) {
|
const multipartForms = enabled(body.multipartForm).concat(disabled(body.multipartForm));
|
||||||
bru += `\n${indentString(
|
|
||||||
enabled(body.multipartForm)
|
|
||||||
.map((item) => `${item.name}: ${item.value}`)
|
|
||||||
.join('\n')
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (disabled(body.multipartForm).length) {
|
if (multipartForms.length) {
|
||||||
bru += `\n${indentString(
|
bru += `\n${indentString(
|
||||||
disabled(body.multipartForm)
|
multipartForms
|
||||||
.map((item) => `~${item.name}: ${item.value}`)
|
.map((item) => {
|
||||||
|
const enabled = item.enabled ? '' : '~';
|
||||||
|
const value = item.isFile ? `@file(${item.value})` : item.value;
|
||||||
|
|
||||||
|
return `${enabled}${item.name}: ${value}`;
|
||||||
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
)}`;
|
)}`;
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ const environmentsSchema = Yup.array().of(environmentSchema);
|
|||||||
|
|
||||||
const keyValueSchema = Yup.object({
|
const keyValueSchema = Yup.object({
|
||||||
uid: uidSchema,
|
uid: uidSchema,
|
||||||
|
isFile: Yup.boolean().nullable(),
|
||||||
name: Yup.string().nullable(),
|
name: Yup.string().nullable(),
|
||||||
value: Yup.string().nullable(),
|
value: Yup.string().nullable(),
|
||||||
description: Yup.string().nullable(),
|
description: Yup.string().nullable(),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user