mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-07 14:08:38 +00:00
Merge pull request #2121 from end3rbyte/feature/1602-multipart-content-type
feature: Multi-part requests: user should be able to set content-type for each part in a multi-part request. #1602
This commit is contained in:
@@ -13,6 +13,12 @@ const StyledWrapper = styled.div`
|
|||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
pre.CodeMirror-placeholder {
|
||||||
|
color: ${(props) => props.theme.text};
|
||||||
|
padding-left: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.CodeMirror-scroll {
|
.CodeMirror-scroll {
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
${'' /* padding-bottom: 50px !important; */}
|
${'' /* padding-bottom: 50px !important; */}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class MultiLineEditor extends Component {
|
|||||||
lineWrapping: false,
|
lineWrapping: false,
|
||||||
lineNumbers: false,
|
lineNumbers: false,
|
||||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||||
|
placeholder: this.props.placeholder,
|
||||||
mode: 'brunovariables',
|
mode: 'brunovariables',
|
||||||
brunoVarInfo: {
|
brunoVarInfo: {
|
||||||
variables
|
variables
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const Wrapper = styled.div`
|
|||||||
td {
|
td {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
|
|
||||||
&:nth-child(1) {
|
&:nth-child(2) {
|
||||||
width: 30%;
|
width: 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(4) {
|
&:nth-child(4) {
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ const Wrapper = styled.div`
|
|||||||
width: 30%;
|
width: 30%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
&:nth-child(3) {
|
&:nth-child(3) {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(4) {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
param.value = e.target.value;
|
param.value = e.target.value;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'contentType': {
|
||||||
|
param.contentType = e.target.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'enabled': {
|
case 'enabled': {
|
||||||
param.enabled = e.target.checked;
|
param.enabled = e.target.checked;
|
||||||
break;
|
break;
|
||||||
@@ -85,6 +89,7 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>Key</td>
|
<td>Key</td>
|
||||||
<td>Value</td>
|
<td>Value</td>
|
||||||
|
<td>Content-Type</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -145,6 +150,27 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<MultiLineEditor
|
||||||
|
onSave={onSave}
|
||||||
|
theme={storedTheme}
|
||||||
|
placeholder="Auto"
|
||||||
|
value={param.contentType}
|
||||||
|
onChange={(newValue) =>
|
||||||
|
handleParamChange(
|
||||||
|
{
|
||||||
|
target: {
|
||||||
|
value: newValue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
param,
|
||||||
|
'contentType'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRun={handleRun}
|
||||||
|
collection={collection}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ if (!SERVER_RENDERED) {
|
|||||||
require('codemirror/addon/search/jump-to-line');
|
require('codemirror/addon/search/jump-to-line');
|
||||||
require('codemirror/addon/search/search');
|
require('codemirror/addon/search/search');
|
||||||
require('codemirror/addon/search/searchcursor');
|
require('codemirror/addon/search/searchcursor');
|
||||||
|
require('codemirror/addon/display/placeholder');
|
||||||
require('codemirror/keymap/sublime');
|
require('codemirror/keymap/sublime');
|
||||||
|
|
||||||
require('codemirror-graphql/hint');
|
require('codemirror-graphql/hint');
|
||||||
|
|||||||
@@ -765,6 +765,7 @@ export const collectionsSlice = createSlice({
|
|||||||
name: '',
|
name: '',
|
||||||
value: action.payload.value,
|
value: action.payload.value,
|
||||||
description: '',
|
description: '',
|
||||||
|
contentType: '',
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -786,6 +787,7 @@ export const collectionsSlice = createSlice({
|
|||||||
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;
|
||||||
|
param.contentType = action.payload.param.contentType;
|
||||||
param.enabled = action.payload.param.enabled;
|
param.enabled = action.payload.param.enabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const EXIT_STATUS = {
|
|||||||
// Invalid output format requested
|
// Invalid output format requested
|
||||||
ERROR_INCORRECT_OUTPUT_FORMAT: 9,
|
ERROR_INCORRECT_OUTPUT_FORMAT: 9,
|
||||||
// Everything else
|
// Everything else
|
||||||
ERROR_GENERIC: 255,
|
ERROR_GENERIC: 255
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
const { get, each, filter, find, compact } = require('lodash');
|
|
||||||
const { get, each, filter } = require('lodash');
|
const { get, each, filter } = require('lodash');
|
||||||
const decomment = require('decomment');
|
const decomment = require('decomment');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { mergeHeaders, mergeScripts, mergeVars, getTreePathFromCollectionToItem } = require('../utils/collection');
|
const { mergeHeaders, mergeScripts, mergeVars, getTreePathFromCollectionToItem } = require('../utils/collection');
|
||||||
|
|
||||||
|
const createFormData = (datas, collectionPath) => {
|
||||||
|
// make axios work in node using form data
|
||||||
|
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
|
||||||
|
const form = new FormData();
|
||||||
|
datas.forEach((item) => {
|
||||||
|
const value = item.value;
|
||||||
|
const name = item.name;
|
||||||
|
let options = {};
|
||||||
|
if (item.contentType) {
|
||||||
|
options.contentType = item.contentType;
|
||||||
|
}
|
||||||
|
if (item.type === 'file') {
|
||||||
|
const filePaths = value || [];
|
||||||
|
filePaths.forEach((filePath) => {
|
||||||
|
let trimmedFilePath = filePath.trim();
|
||||||
|
|
||||||
|
if (!path.isAbsolute(trimmedFilePath)) {
|
||||||
|
trimmedFilePath = path.join(collectionPath, trimmedFilePath);
|
||||||
|
}
|
||||||
|
options.filename = path.basename(trimmedFilePath);
|
||||||
|
form.append(name, fs.createReadStream(trimmedFilePath), options);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.append(name, value, options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
};
|
||||||
|
|
||||||
const prepareRequest = (item = {}, collection = {}) => {
|
const prepareRequest = (item = {}, collection = {}) => {
|
||||||
const request = item?.request;
|
const request = item?.request;
|
||||||
const brunoConfig = get(collection, 'brunoConfig', {});
|
const brunoConfig = get(collection, 'brunoConfig', {});
|
||||||
@@ -132,13 +160,11 @@ const prepareRequest = (item = {}, collection = {}) => {
|
|||||||
each(enabledParams, (p) => (params[p.name] = p.value));
|
each(enabledParams, (p) => (params[p.name] = p.value));
|
||||||
axiosRequest.data = params;
|
axiosRequest.data = params;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.body.mode === 'multipartForm') {
|
if (request.body.mode === 'multipartForm') {
|
||||||
axiosRequest.headers['content-type'] = 'multipart/form-data';
|
axiosRequest.headers['content-type'] = 'multipart/form-data';
|
||||||
const params = {};
|
|
||||||
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
|
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
|
||||||
each(enabledParams, (p) => (params[p.name] = p.value));
|
axiosRequest.data = createFormData(enabledParams);
|
||||||
axiosRequest.data = params;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.body.mode === 'graphql') {
|
if (request.body.mode === 'graphql') {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const grammar = ohm.grammar(`Bru {
|
|||||||
pair = st* key st* ":" st* value st*
|
pair = st* key st* ":" st* value st*
|
||||||
key = keychar*
|
key = keychar*
|
||||||
value = multilinetextblock | valuechar*
|
value = multilinetextblock | valuechar*
|
||||||
|
|
||||||
// Dictionary for Assert Block
|
// Dictionary for Assert Block
|
||||||
assertdictionary = st* "{" assertpairlist? tagend
|
assertdictionary = st* "{" assertpairlist? tagend
|
||||||
assertpairlist = optionalnl* assertpair (~tagend stnl* assertpair)* (~tagend space)*
|
assertpairlist = optionalnl* assertpair (~tagend stnl* assertpair)* (~tagend space)*
|
||||||
@@ -160,16 +160,31 @@ const mapRequestParams = (pairList = [], type) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const multipartExtractContentType = (pair) => {
|
||||||
|
if (_.isString(pair.value)) {
|
||||||
|
const match = pair.value.match(/^(.*?)\s*\(Content-Type=(.*?)\)\s*$/);
|
||||||
|
if (match != null && match.length > 2) {
|
||||||
|
pair.value = match[1];
|
||||||
|
pair.contentType = match[2];
|
||||||
|
} else {
|
||||||
|
pair.contentType = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => {
|
const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => {
|
||||||
const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
|
const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
|
||||||
|
|
||||||
return pairs.map((pair) => {
|
return pairs.map((pair) => {
|
||||||
pair.type = 'text';
|
pair.type = 'text';
|
||||||
|
multipartExtractContentType(pair);
|
||||||
|
|
||||||
if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
|
if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
|
||||||
let filestr = pair.value.replace(/^@file\(/, '').replace(/\)$/, '');
|
let filestr = pair.value.replace(/^@file\(/, '').replace(/\)$/, '');
|
||||||
pair.type = 'file';
|
pair.type = 'file';
|
||||||
pair.value = filestr.split('|');
|
pair.value = filestr.split('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
return pair;
|
return pair;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -280,15 +280,18 @@ ${indentString(body.sparql)}
|
|||||||
multipartForms
|
multipartForms
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const enabled = item.enabled ? '' : '~';
|
const enabled = item.enabled ? '' : '~';
|
||||||
|
const contentType =
|
||||||
|
item.contentType && item.contentType !== '' ? ' (Content-Type=' + item.contentType + ')' : '';
|
||||||
|
|
||||||
if (item.type === 'text') {
|
if (item.type === 'text') {
|
||||||
return `${enabled}${item.name}: ${getValueString(item.value)}`;
|
return `${enabled}${item.name}: ${getValueString(item.value)}${contentType}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === 'file') {
|
if (item.type === 'file') {
|
||||||
let filepaths = item.value || [];
|
let filepaths = item.value || [];
|
||||||
let filestr = filepaths.join('|');
|
let filestr = filepaths.join('|');
|
||||||
const value = `@file(${filestr})`;
|
const value = `@file(${filestr})`;
|
||||||
return `${enabled}${item.name}: ${value}`;
|
return `${enabled}${item.name}: ${value}${contentType}`;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|||||||
@@ -117,18 +117,21 @@
|
|||||||
],
|
],
|
||||||
"multipartForm": [
|
"multipartForm": [
|
||||||
{
|
{
|
||||||
|
"contentType": "",
|
||||||
"name": "apikey",
|
"name": "apikey",
|
||||||
"value": "secret",
|
"value": "secret",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"type": "text"
|
"type": "text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"contentType": "",
|
||||||
"name": "numbers",
|
"name": "numbers",
|
||||||
"value": "+91998877665",
|
"value": "+91998877665",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"type": "text"
|
"type": "text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"contentType": "",
|
||||||
"name": "message",
|
"name": "message",
|
||||||
"value": "hello",
|
"value": "hello",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const multipartFormSchema = Yup.object({
|
|||||||
otherwise: Yup.string().nullable()
|
otherwise: Yup.string().nullable()
|
||||||
}),
|
}),
|
||||||
description: Yup.string().nullable(),
|
description: Yup.string().nullable(),
|
||||||
|
contentType: Yup.string().nullable(),
|
||||||
enabled: Yup.boolean()
|
enabled: Yup.boolean()
|
||||||
})
|
})
|
||||||
.noUnknown(true)
|
.noUnknown(true)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
meta {
|
||||||
|
name: mixed-content-types
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{host}}/api/multipart/mixed-content-types
|
||||||
|
body: multipartForm
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:multipart-form {
|
||||||
|
param1: test
|
||||||
|
param2: {"test":"i am json"} (Content-Type=application/json)
|
||||||
|
param3: @file(multipart/small.png)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
res.status: eq 200
|
||||||
|
res.body.find(p=>p.name === 'param1').contentType: isUndefined
|
||||||
|
res.body.find(p=>p.name === 'param2').contentType: eq application/json
|
||||||
|
res.body.find(p=>p.name === 'param3').contentType: eq image/png
|
||||||
|
}
|
||||||
BIN
packages/bruno-tests/collection/multipart/small.png
Normal file
BIN
packages/bruno-tests/collection/multipart/small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 B |
@@ -1,23 +1,24 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const multer = require('multer');
|
const formDataParser = require('./multipart/form-data-parser');
|
||||||
const authRouter = require('./auth');
|
const authRouter = require('./auth');
|
||||||
const echoRouter = require('./echo');
|
const echoRouter = require('./echo');
|
||||||
const xmlParser = require('./utils/xmlParser');
|
const xmlParser = require('./utils/xmlParser');
|
||||||
|
|
||||||
const app = new express();
|
const app = new express();
|
||||||
const port = process.env.PORT || 8080;
|
const port = process.env.PORT || 8080;
|
||||||
const upload = multer();
|
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(xmlParser());
|
app.use(xmlParser());
|
||||||
app.use(bodyParser.text());
|
app.use(bodyParser.text());
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
app.use(bodyParser.urlencoded({ extended: true }));
|
app.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
formDataParser.init(app, express);
|
||||||
|
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
app.use('/api/echo', echoRouter);
|
app.use('/api/echo', echoRouter);
|
||||||
|
app.use('/api/multipart', multipartRouter);
|
||||||
|
|
||||||
app.get('/ping', function (req, res) {
|
app.get('/ping', function (req, res) {
|
||||||
return res.send('pong');
|
return res.send('pong');
|
||||||
@@ -31,10 +32,6 @@ app.get('/query', function (req, res) {
|
|||||||
return res.json(req.query);
|
return res.json(req.query);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/echo/multipartForm', upload.none(), function (req, res) {
|
|
||||||
return res.json(req.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/redirect-to-ping', function (req, res) {
|
app.get('/redirect-to-ping', function (req, res) {
|
||||||
return res.redirect('/ping');
|
return res.redirect('/ping');
|
||||||
});
|
});
|
||||||
|
|||||||
58
packages/bruno-tests/src/multipart/form-data-parser.js
Normal file
58
packages/bruno-tests/src/multipart/form-data-parser.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Instead of using multer for example to parse the multipart form data, we build our own parser
|
||||||
|
* so that we can verify the content type are set correctly by bruno (for example application/json for json content)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const extractParam = function (param, str, delimiter, quote, endDelimiter) {
|
||||||
|
let regex = new RegExp(`${param}${delimiter}\\s*${quote}(.*?)${quote}${endDelimiter}`);
|
||||||
|
const found = str.match(regex);
|
||||||
|
if (found != null && found.length > 1) {
|
||||||
|
return found[1];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = function (app, express) {
|
||||||
|
app.use(express.raw({ type: 'multipart/form-data' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePart = function (part) {
|
||||||
|
let result = {};
|
||||||
|
const name = extractParam('name', part, '=', '"', '');
|
||||||
|
if (name) {
|
||||||
|
result.name = name;
|
||||||
|
}
|
||||||
|
const filename = extractParam('filename', part, '=', '"', '');
|
||||||
|
if (filename) {
|
||||||
|
result.filename = filename;
|
||||||
|
}
|
||||||
|
const contentType = extractParam('Content-Type', part, ':', '', ';');
|
||||||
|
if (contentType) {
|
||||||
|
result.contentType = contentType;
|
||||||
|
}
|
||||||
|
if (!filename) {
|
||||||
|
result.value = part.substring(part.indexOf('value=') + 'value='.length);
|
||||||
|
}
|
||||||
|
if (contentType === 'application/json') {
|
||||||
|
result.value = JSON.parse(result.value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parse = function (req) {
|
||||||
|
const BOUNDARY = 'boundary=';
|
||||||
|
const contentType = req.headers['content-type'];
|
||||||
|
const boundary = '--' + contentType.substring(contentType.indexOf(BOUNDARY) + BOUNDARY.length);
|
||||||
|
const rawBody = req.body.toString();
|
||||||
|
let parts = rawBody.split(boundary).filter((part) => part.length > 0);
|
||||||
|
parts = parts.map((part) => part.trim('\r\n'));
|
||||||
|
parts = parts.filter((part) => part != '--');
|
||||||
|
parts = parts.map((part) => part.replace('\r\n\r\n', ';value='));
|
||||||
|
parts = parts.map((part) => part.replace('\r\n', ';'));
|
||||||
|
parts = parts.map((part) => parsePart(part));
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.parse = parse;
|
||||||
|
module.exports.init = init;
|
||||||
10
packages/bruno-tests/src/multipart/index.js
Normal file
10
packages/bruno-tests/src/multipart/index.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const formDataParser = require('./form-data-parser');
|
||||||
|
|
||||||
|
router.post('/mixed-content-types', (req, res) => {
|
||||||
|
const parts = formDataParser.parse(req);
|
||||||
|
return res.json(parts);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user