diff --git a/packages/bruno-app/src/components/MultiLineEditor/StyledWrapper.js b/packages/bruno-app/src/components/MultiLineEditor/StyledWrapper.js index 50c806d9f0..2473463db3 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/MultiLineEditor/StyledWrapper.js @@ -13,6 +13,12 @@ const StyledWrapper = styled.div` line-height: 30px; overflow: hidden; + pre.CodeMirror-placeholder { + color: ${(props) => props.theme.text}; + padding-left: 0; + opacity: 0.5; + } + .CodeMirror-scroll { overflow: hidden !important; ${'' /* padding-bottom: 50px !important; */} diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index 97b3eca07a..a44caf4ba8 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -30,6 +30,7 @@ class MultiLineEditor extends Component { lineWrapping: false, lineNumbers: false, theme: this.props.theme === 'dark' ? 'monokai' : 'default', + placeholder: this.props.placeholder, mode: 'brunovariables', brunoVarInfo: { variables diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Assertions/StyledWrapper.js index 6f28fee13b..14e21e1c60 100644 --- a/packages/bruno-app/src/components/RequestPane/Assertions/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/Assertions/StyledWrapper.js @@ -20,8 +20,8 @@ const Wrapper = styled.div` td { padding: 6px 10px; - &:nth-child(1) { - width: 30%; + &:nth-child(2) { + width: 130px; } &:nth-child(4) { diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js index f04a30be0f..49c45f21dc 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js @@ -24,7 +24,15 @@ const Wrapper = styled.div` width: 30%; } + &:nth-child(2) { + width: 45%; + } + &:nth-child(3) { + width: 25%; + } + + &:nth-child(4) { width: 70px; } } diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js index 0d57d97eae..af23a645e6 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js @@ -54,6 +54,10 @@ const MultipartFormParams = ({ item, collection }) => { param.value = e.target.value; break; } + case 'contentType': { + param.contentType = e.target.value; + break; + } case 'enabled': { param.enabled = e.target.checked; break; @@ -85,6 +89,7 @@ const MultipartFormParams = ({ item, collection }) => { Key Value + Content-Type @@ -145,6 +150,27 @@ const MultipartFormParams = ({ item, collection }) => { /> )} + + + handleParamChange( + { + target: { + value: newValue + } + }, + param, + 'contentType' + ) + } + onRun={handleRun} + collection={collection} + /> +
{ + // 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 request = item?.request; const brunoConfig = get(collection, 'brunoConfig', {}); @@ -132,13 +160,11 @@ const prepareRequest = (item = {}, collection = {}) => { each(enabledParams, (p) => (params[p.name] = p.value)); axiosRequest.data = params; } - + if (request.body.mode === 'multipartForm') { axiosRequest.headers['content-type'] = 'multipart/form-data'; - const params = {}; const enabledParams = filter(request.body.multipartForm, (p) => p.enabled); - each(enabledParams, (p) => (params[p.name] = p.value)); - axiosRequest.data = params; + axiosRequest.data = createFormData(enabledParams); } if (request.body.mode === 'graphql') { diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index c84d36d073..1324438900 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -46,7 +46,7 @@ const grammar = ohm.grammar(`Bru { pair = st* key st* ":" st* value st* key = keychar* value = multilinetextblock | valuechar* - + // Dictionary for Assert Block assertdictionary = st* "{" assertpairlist? tagend 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 pairs = mapPairListToKeyValPairs(pairList, parseEnabled); return pairs.map((pair) => { pair.type = 'text'; + multipartExtractContentType(pair); + if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) { let filestr = pair.value.replace(/^@file\(/, '').replace(/\)$/, ''); pair.type = 'file'; pair.value = filestr.split('|'); } + return pair; }); }; diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 8d3a5fdee8..03e36c71a8 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -280,15 +280,18 @@ ${indentString(body.sparql)} multipartForms .map((item) => { const enabled = item.enabled ? '' : '~'; + const contentType = + item.contentType && item.contentType !== '' ? ' (Content-Type=' + item.contentType + ')' : ''; + if (item.type === 'text') { - return `${enabled}${item.name}: ${getValueString(item.value)}`; + return `${enabled}${item.name}: ${getValueString(item.value)}${contentType}`; } if (item.type === 'file') { let filepaths = item.value || []; let filestr = filepaths.join('|'); const value = `@file(${filestr})`; - return `${enabled}${item.name}: ${value}`; + return `${enabled}${item.name}: ${value}${contentType}`; } }) .join('\n') diff --git a/packages/bruno-lang/v2/tests/fixtures/request.json b/packages/bruno-lang/v2/tests/fixtures/request.json index 24997a90c7..9c8ed143da 100644 --- a/packages/bruno-lang/v2/tests/fixtures/request.json +++ b/packages/bruno-lang/v2/tests/fixtures/request.json @@ -117,18 +117,21 @@ ], "multipartForm": [ { + "contentType": "", "name": "apikey", "value": "secret", "enabled": true, "type": "text" }, { + "contentType": "", "name": "numbers", "value": "+91998877665", "enabled": true, "type": "text" }, { + "contentType": "", "name": "message", "value": "hello", "enabled": false, diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 11561c5284..3d5959f15d 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -68,6 +68,7 @@ const multipartFormSchema = Yup.object({ otherwise: Yup.string().nullable() }), description: Yup.string().nullable(), + contentType: Yup.string().nullable(), enabled: Yup.boolean() }) .noUnknown(true) diff --git a/packages/bruno-tests/collection/multipart/mixed-content-types.bru b/packages/bruno-tests/collection/multipart/mixed-content-types.bru new file mode 100644 index 0000000000..45a1cdd184 --- /dev/null +++ b/packages/bruno-tests/collection/multipart/mixed-content-types.bru @@ -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 +} diff --git a/packages/bruno-tests/collection/multipart/small.png b/packages/bruno-tests/collection/multipart/small.png new file mode 100644 index 0000000000..2b584adf05 Binary files /dev/null and b/packages/bruno-tests/collection/multipart/small.png differ diff --git a/packages/bruno-tests/src/index.js b/packages/bruno-tests/src/index.js index a6c72dc2b3..0bff6ab752 100644 --- a/packages/bruno-tests/src/index.js +++ b/packages/bruno-tests/src/index.js @@ -1,23 +1,24 @@ const express = require('express'); const bodyParser = require('body-parser'); const cors = require('cors'); -const multer = require('multer'); +const formDataParser = require('./multipart/form-data-parser'); const authRouter = require('./auth'); const echoRouter = require('./echo'); const xmlParser = require('./utils/xmlParser'); const app = new express(); const port = process.env.PORT || 8080; -const upload = multer(); app.use(cors()); app.use(xmlParser()); app.use(bodyParser.text()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); +formDataParser.init(app, express); app.use('/api/auth', authRouter); app.use('/api/echo', echoRouter); +app.use('/api/multipart', multipartRouter); app.get('/ping', function (req, res) { return res.send('pong'); @@ -31,10 +32,6 @@ app.get('/query', function (req, res) { 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) { return res.redirect('/ping'); }); diff --git a/packages/bruno-tests/src/multipart/form-data-parser.js b/packages/bruno-tests/src/multipart/form-data-parser.js new file mode 100644 index 0000000000..8b4aa500ad --- /dev/null +++ b/packages/bruno-tests/src/multipart/form-data-parser.js @@ -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; diff --git a/packages/bruno-tests/src/multipart/index.js b/packages/bruno-tests/src/multipart/index.js new file mode 100644 index 0000000000..a98837c54a --- /dev/null +++ b/packages/bruno-tests/src/multipart/index.js @@ -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;