mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 05:35:41 +00:00
Feat: Support multipart/mixed (#7155)
* feat(): support multipart mixed fix: support vars interpolation on mixed multi-part Update packages/bruno-electron/src/ipc/network/interpolate-vars.js Co-authored-by: Timon <39559178+Its-treason@users.noreply.github.com> refactor: use startsWith feat: best effort for other multipart/* contentypes * feat: enhance variable interpolation for multipart requests - Updated `interpolateVars` function to support interpolation in multipart/form-data and multipart/mixed requests. - Added handling for empty multipart arrays and parts with missing or undefined values. - Improved type checks for content types to ensure proper interpolation behavior. Includes new tests to validate the interpolation functionality for multipart requests. * fix: normalize error handling in sendRequest and improve test reliability --------- Co-authored-by: Alfonso Presa <alfonso-presa@users.noreply.github.com>
This commit is contained in:
@@ -96,7 +96,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
|||||||
value: _interpolate(d?.value)
|
value: _interpolate(d?.value)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} else if (contentType === 'multipart/form-data') {
|
} else if (contentType.startsWith('multipart/')) {
|
||||||
if (Array.isArray(request?.data) && !isFormData(request.data)) {
|
if (Array.isArray(request?.data) && !isFormData(request.data)) {
|
||||||
try {
|
try {
|
||||||
request.data = request?.data?.map((d) => ({
|
request.data = request?.data?.map((d) => ({
|
||||||
|
|||||||
@@ -540,12 +540,23 @@ const runSingleRequest = async function (
|
|||||||
// if `data` is of string type - return as-is (assumes already encoded)
|
// if `data` is of string type - return as-is (assumes already encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
|
if (contentTypeHeader && contentTypeHeader.startsWith('multipart/')) {
|
||||||
if (!isFormData(request?.data)) {
|
if (!isFormData(request?.data)) {
|
||||||
request._originalMultipartData = request.data;
|
request._originalMultipartData = request.data;
|
||||||
request.collectionPath = collectionPath;
|
request.collectionPath = collectionPath;
|
||||||
let form = createFormData(request.data, collectionPath);
|
let form = createFormData(request.data, collectionPath);
|
||||||
request.data = form;
|
request.data = form;
|
||||||
|
|
||||||
|
if (request?.headers?.['content-type'] !== 'multipart/form-data') {
|
||||||
|
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
|
||||||
|
const formHeaders = form.getHeaders();
|
||||||
|
const ct = request.headers['content-type'];
|
||||||
|
formHeaders['content-type'] = `${ct}; boundary=${form.getBoundary()}`;
|
||||||
|
form.getHeaders = function () {
|
||||||
|
return formHeaders;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
extend(request.headers, form.getHeaders());
|
extend(request.headers, form.getHeaders());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -582,12 +582,22 @@ const registerNetworkIpc = (mainWindow) => {
|
|||||||
// if `data` is of string type - return as-is (assumes already encoded)
|
// if `data` is of string type - return as-is (assumes already encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
|
if (contentTypeHeader && contentTypeHeader.startsWith('multipart/')) {
|
||||||
if (!isFormData(request.data)) {
|
if (!isFormData(request.data)) {
|
||||||
request._originalMultipartData = request.data;
|
request._originalMultipartData = request.data;
|
||||||
request.collectionPath = collectionPath;
|
request.collectionPath = collectionPath;
|
||||||
let form = createFormData(request.data, collectionPath);
|
let form = createFormData(request.data, collectionPath);
|
||||||
request.data = form;
|
request.data = form;
|
||||||
|
if (contentTypeHeader !== 'multipart/form-data') {
|
||||||
|
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
|
||||||
|
const formHeaders = form.getHeaders();
|
||||||
|
const ct = contentTypeHeader;
|
||||||
|
formHeaders['content-type'] = `${ct}; boundary=${form.getBoundary()}`;
|
||||||
|
form.getHeaders = function () {
|
||||||
|
return formHeaders;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
extend(request.headers, form.getHeaders());
|
extend(request.headers, form.getHeaders());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,11 +76,10 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
|||||||
const contentType = getContentType(request.headers);
|
const contentType = getContentType(request.headers);
|
||||||
const isGraphqlRequest = request.mode === 'graphql';
|
const isGraphqlRequest = request.mode === 'graphql';
|
||||||
|
|
||||||
if (isGrpcRequest) {
|
// gRPC: interpolate entire body (JSON message template and any other keys).
|
||||||
|
if (isGrpcRequest && request.body) {
|
||||||
const jsonDoc = JSON.stringify(request.body);
|
const jsonDoc = JSON.stringify(request.body);
|
||||||
const parsed = _interpolate(jsonDoc, {
|
const parsed = _interpolate(jsonDoc, { escapeJSONStrings: true });
|
||||||
escapeJSONStrings: true
|
|
||||||
});
|
|
||||||
request.body = JSON.parse(parsed);
|
request.body = JSON.parse(parsed);
|
||||||
}
|
}
|
||||||
// Interpolate WebSocket message body
|
// Interpolate WebSocket message body
|
||||||
@@ -88,15 +87,11 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
|||||||
if (isWsRequest && request.body && request.body.ws && Array.isArray(request.body.ws)) {
|
if (isWsRequest && request.body && request.body.ws && Array.isArray(request.body.ws)) {
|
||||||
request.body.ws.forEach((message) => {
|
request.body.ws.forEach((message) => {
|
||||||
if (message && message.content) {
|
if (message && message.content) {
|
||||||
// Try to detect if content is JSON for proper escaping
|
|
||||||
let isJson = false;
|
let isJson = false;
|
||||||
try {
|
try {
|
||||||
JSON.parse(message.content);
|
JSON.parse(message.content);
|
||||||
isJson = true;
|
isJson = true;
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
// Not JSON, treat as regular string
|
|
||||||
}
|
|
||||||
|
|
||||||
message.content = _interpolate(message.content, {
|
message.content = _interpolate(message.content, {
|
||||||
escapeJSONStrings: isJson
|
escapeJSONStrings: isJson
|
||||||
});
|
});
|
||||||
@@ -138,7 +133,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
|||||||
value: _interpolate(d?.value)
|
value: _interpolate(d?.value)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} else if (contentType === 'multipart/form-data') {
|
} else if (contentType.startsWith('multipart/')) {
|
||||||
if (Array.isArray(request?.data) && !isFormData(request.data)) {
|
if (Array.isArray(request?.data) && !isFormData(request.data)) {
|
||||||
try {
|
try {
|
||||||
request.data = request?.data?.map((d) => ({
|
request.data = request?.data?.map((d) => ({
|
||||||
|
|||||||
@@ -294,4 +294,136 @@ describe('interpolate-vars: interpolateVars', () => {
|
|||||||
expect(result.data).toEqual(undefined);
|
expect(result.data).toEqual(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Multipart body (multipart/form-data and multipart/mixed)', () => {
|
||||||
|
it('interpolates value in each part when Content-Type is multipart/form-data', () => {
|
||||||
|
const request = {
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://api.example/upload',
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data; boundary=----boundary' },
|
||||||
|
data: [
|
||||||
|
{ name: 'field1', value: '{{token}}', type: 'text' },
|
||||||
|
{ name: 'field2', value: 'static', type: 'text' },
|
||||||
|
{ name: 'field3', value: '{{prefix}}-suffix', type: 'text' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = interpolateVars(
|
||||||
|
request,
|
||||||
|
{ token: 'secret123', prefix: 'my' },
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.data).toEqual([
|
||||||
|
{ name: 'field1', value: 'secret123', type: 'text' },
|
||||||
|
{ name: 'field2', value: 'static', type: 'text' },
|
||||||
|
{ name: 'field3', value: 'my-suffix', type: 'text' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolates value in each part when Content-Type is multipart/mixed', () => {
|
||||||
|
const request = {
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://api.example/send',
|
||||||
|
headers: { 'Content-Type': 'multipart/mixed; boundary=----mixed' },
|
||||||
|
data: [
|
||||||
|
{ name: 'part1', value: '{{envVar}}', type: 'text' },
|
||||||
|
{ name: 'part2', value: '{{another}}', type: 'text' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = interpolateVars(
|
||||||
|
request,
|
||||||
|
{ envVar: 'first', another: 'second' },
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.data).toEqual([
|
||||||
|
{ name: 'part1', value: 'first', type: 'text' },
|
||||||
|
{ name: 'part2', value: 'second', type: 'text' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves part keys (name, type, etc.) intact and only interpolates value', () => {
|
||||||
|
const request = {
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://api.example/upload',
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
data: [
|
||||||
|
{ name: 'file', value: '{{path}}', type: 'file', fileName: 'doc.pdf' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = interpolateVars(request, { path: '/tmp/doc.pdf' }, null, null);
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.data[0].name).toBe('file');
|
||||||
|
expect(result.data[0].type).toBe('file');
|
||||||
|
expect(result.data[0].fileName).toBe('doc.pdf');
|
||||||
|
expect(result.data[0].value).toBe('/tmp/doc.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty multipart array', () => {
|
||||||
|
const request = {
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://api.example/upload',
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
data: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = interpolateVars(request, { x: 'y' }, null, null);
|
||||||
|
|
||||||
|
expect(result.data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles part with missing or undefined value', () => {
|
||||||
|
const request = {
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://api.example/upload',
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
data: [
|
||||||
|
{ name: 'a', value: '{{present}}' },
|
||||||
|
{ name: 'b' },
|
||||||
|
{ name: 'c', value: undefined }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = interpolateVars(request, { present: 'ok' }, null, null);
|
||||||
|
|
||||||
|
expect(result.data[0].value).toBe('ok');
|
||||||
|
expect(result.data[1].value).toBeUndefined();
|
||||||
|
expect(result.data[2].value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves raw string body when Content-Type is multipart/mixed (manually constructed multipart)', () => {
|
||||||
|
// Equivalent to: curl -X POST https://httpbin.dev/post \
|
||||||
|
// -H 'content-type: multipart/mixed; boundary=TestBoundary123' \
|
||||||
|
// --data '--TestBoundary123\r\nContent-Type: application/json\r\n\r\n{"test": true}\r\n--TestBoundary123--\r\n'
|
||||||
|
const rawMultipartBody = [
|
||||||
|
'--TestBoundary123',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'',
|
||||||
|
'{"test": true}',
|
||||||
|
'--TestBoundary123--',
|
||||||
|
''
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://httpbin.dev/post',
|
||||||
|
headers: { 'content-type': 'multipart/mixed; boundary=TestBoundary123' },
|
||||||
|
data: rawMultipartBody
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = interpolateVars(request, {}, null, null);
|
||||||
|
|
||||||
|
expect(result.data).toBe(rawMultipartBody);
|
||||||
|
expect(result.data).toContain('--TestBoundary123');
|
||||||
|
expect(result.data).toContain('Content-Type: application/json');
|
||||||
|
expect(result.data).toContain('{"test": true}');
|
||||||
|
expect(result.data).toContain('--TestBoundary123--');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -471,20 +471,26 @@ const addBruShimToContext = (vm, bru) => {
|
|||||||
bruObject.dispose();
|
bruObject.dispose();
|
||||||
|
|
||||||
vm.evalCode(`
|
vm.evalCode(`
|
||||||
|
// sendRequest with callback: normalize error.status (axios uses error.response.status) so
|
||||||
|
// tests like expect(error.status).to.eql(404) pass in safe sandbox; return response after
|
||||||
|
// success callback for consistent promise resolution.
|
||||||
globalThis.bru.sendRequest = async (requestConfig, callback) => {
|
globalThis.bru.sendRequest = async (requestConfig, callback) => {
|
||||||
if (!callback) return await globalThis.bru._sendRequest(requestConfig);
|
if (!callback) return await globalThis.bru._sendRequest(requestConfig);
|
||||||
try {
|
try {
|
||||||
const response = await globalThis.bru._sendRequest(requestConfig);
|
const response = await globalThis.bru._sendRequest(requestConfig);
|
||||||
try {
|
try {
|
||||||
await callback(null, response);
|
await callback(null, response);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
catch(error) {
|
catch(error) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(error) {
|
catch(error) {
|
||||||
|
const errObj = JSON.parse(JSON.stringify(error));
|
||||||
|
if (errObj && errObj.response && typeof errObj.response.status === 'number') errObj.status = errObj.response.status;
|
||||||
try {
|
try {
|
||||||
await callback(JSON.parse(JSON.stringify(error)), null);
|
await callback(errObj, null);
|
||||||
}
|
}
|
||||||
catch(err) {
|
catch(err) {
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
|
|||||||
@@ -58,9 +58,16 @@ const createSendRequest = (config?: SendRequestConfig) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
// Normalize axios error for callback: tests expect error.status (e.g. 404), but axios
|
||||||
|
// puts the status on error.response.status. Setting status here ensures the same
|
||||||
|
// behaviour in nodevm (--sandbox developer, used in CI) and in QuickJS (safe sandbox).
|
||||||
|
const errForCallback
|
||||||
|
= error && typeof error.response?.status === 'number'
|
||||||
|
? { ...error, status: error.response.status }
|
||||||
|
: error;
|
||||||
try {
|
try {
|
||||||
await callback(error, null);
|
await callback(errForCallback, null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ script:pre-request {
|
|||||||
|
|
||||||
tests {
|
tests {
|
||||||
const jar = bru.cookies.jar()
|
const jar = bru.cookies.jar()
|
||||||
|
// Await so the callback runs before jar.clear() below; otherwise the test script can finish
|
||||||
jar.getCookie("https://testbench-sanity.usebruno.com", "name", function(error, data) {
|
// before the callback registers/runs the test, causing a flaky failure (e.g. in CI).
|
||||||
|
await jar.getCookie("https://testbench-sanity.usebruno.com", "name", function(error, data) {
|
||||||
if(error) {
|
if(error) {
|
||||||
console.error("Cookie retrieval error:", error)
|
console.error("Cookie retrieval error:", error)
|
||||||
throw new Error(`Failed to get cookie: ${error.message || error}`)
|
throw new Error(`Failed to get cookie: ${error.message || error}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user