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:
lohit
2024-12-15 18:08:44 +05:30
committed by GitHub
18 changed files with 198 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

@@ -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', {});
@@ -135,10 +163,8 @@ const prepareRequest = (item = {}, collection = {}) => {
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') {

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

View File

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

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

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