Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/bruno-cli/src/runner/prepare-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const fs = require('node:fs');
const { mergeHeaders, mergeScripts, mergeVars, mergeAuth, getTreePathFromCollectionToItem } = require('../utils/collection');
const path = require('node:path');
const { isLargeFile } = require('../utils/filesystem');
const { createFormData } = require('../utils/form-data');
const { getFormattedOauth2Credentials } = require('../utils/oauth2');

const STREAMING_FILE_SIZE_THRESHOLD = 20 * 1024 * 1024; // 20MB
Expand Down Expand Up @@ -403,9 +404,10 @@ const prepareRequest = async (item = {}, collection = {}) => {
}

if (request.body.mode === 'multipartForm') {
axiosRequest.headers['content-type'] = 'multipart/form-data';
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
axiosRequest.data = enabledParams;
const form = createFormData(enabledParams, collectionPath, STREAMING_FILE_SIZE_THRESHOLD);
Object.assign(axiosRequest.headers, form.getHeaders());
axiosRequest.data = form;
}

if (request.body.mode === 'graphql') {
Expand Down
35 changes: 23 additions & 12 deletions packages/bruno-cli/src/utils/form-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ const { forEach } = require('lodash');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
const { isLargeFile } = require('./filesystem');

const createFormData = (data, collectionPath) => {
const STREAMING_FILE_SIZE_THRESHOLD = 20 * 1024 * 1024; // 20MB

const createFormData = (data, collectionPath, streamThreshold = STREAMING_FILE_SIZE_THRESHOLD) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
const form = new FormData();
Expand All @@ -13,25 +16,33 @@ const createFormData = (data, collectionPath) => {
if (contentType) {
options.contentType = contentType;
}
if (type === 'text') {
if (Array.isArray(value)) {
value.forEach((val) => form.append(name, val, options));
} else {
form.append(name, value, options);
}
return;
}

if (type === 'file') {
const filePaths = value || [];
const filePaths = Array.isArray(value) ? value : (value ? [value] : []);
filePaths.forEach((filePath) => {
if (!filePath) return;
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);
try {
form.append(
name,
isLargeFile(trimmedFilePath, streamThreshold)
? fs.createReadStream(trimmedFilePath)
: fs.readFileSync(trimmedFilePath),
options
);
} catch (error) {
console.error('Error reading file:', error);
}
});
} else {
if (Array.isArray(value)) {
value.forEach((val) => form.append(name, val ?? '', options));
} else {
form.append(name, value ?? '', options);
}
}
});
return form;
Expand Down
48 changes: 48 additions & 0 deletions packages/bruno-cli/tests/utils/form-data.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const { createFormData } = require('../../src/utils/form-data');
const os = require('os');

const collectionPath = os.tmpdir();

describe('utils: createFormData', () => {
test('includes per-part Content-Type for text field with contentType', () => {
const data = [
{
name: 'message',
type: 'text',
value: '{"key":"val"}',
contentType: 'application/json; charset=utf-8'
}
];
const form = createFormData(data, collectionPath);
const buffer = form.getBuffer().toString();
expect(buffer).toContain('Content-Type: application/json; charset=utf-8');
});

test('omits Content-Type header for text field without contentType', () => {
const data = [{ name: 'field', type: 'text', value: 'hello' }];
const form = createFormData(data, collectionPath);
const buffer = form.getBuffer().toString();
expect(buffer).toContain('name="field"');
expect(buffer).toContain('hello');
expect(buffer).not.toContain('Content-Type:');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('appends multiple text values from an array as separate parts', () => {
const data = [{ name: 'tag', type: 'text', value: ['a', 'b'] }];
const form = createFormData(data, collectionPath);
const buffer = form.getBuffer().toString();
const matches = buffer.match(/name="tag"/g) || [];
expect(matches.length).toBe(2);
});

test('handles single-string file value (not wrapped in array)', () => {
const data = [{ name: 'file', type: 'file', value: 'nonexistent.txt' }];
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
expect(() => createFormData(data, collectionPath)).not.toThrow();
expect(consoleSpy).toHaveBeenCalled();
} finally {
consoleSpy.mockRestore();
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
10 changes: 6 additions & 4 deletions packages/bruno-electron/src/ipc/network/prepare-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const fs = require('node:fs');
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars, getFormattedCollectionOauth2Credentials, mergeAuth } = require('../../utils/collection');
const path = require('node:path');
const { isLargeFile } = require('../../utils/filesystem');
const { createFormData } = require('../../utils/form-data');

const STREAMING_FILE_SIZE_THRESHOLD = 20 * 1024 * 1024; // 20MB

Expand Down Expand Up @@ -473,11 +474,12 @@ const prepareRequest = async (item, collection = {}, abortController) => {
}

if (request.body.mode === 'multipartForm') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'multipart/form-data';
}
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
axiosRequest.data = enabledParams;
axiosRequest._originalMultipartData = enabledParams;
axiosRequest.collectionPath = collectionPath;
const form = createFormData(enabledParams, collectionPath, STREAMING_FILE_SIZE_THRESHOLD);
Object.assign(axiosRequest.headers, form.getHeaders());
axiosRequest.data = form;
}

if (request.body.mode === 'graphql') {
Expand Down
63 changes: 34 additions & 29 deletions packages/bruno-electron/src/utils/form-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ const { forEach } = require('lodash');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
const { isLargeFile } = require('./filesystem');

const STREAMING_FILE_SIZE_THRESHOLD = 20 * 1024 * 1024; // 20MB

const formatMultipartData = (multipartData, boundary) => {
if (!Array.isArray(multipartData) || multipartData.length === 0) {
Expand All @@ -20,45 +23,39 @@ const formatMultipartData = (multipartData, boundary) => {
return 'file';
};

const formatValue = (value) => {
if (Array.isArray(value)) {
return value.map((v) => String(v ?? '')).join(', ');
}
return String(value ?? '');
};

const boundaryValue = normalizeBoundary(boundary);
const parts = [];

multipartData.forEach((field) => {
if (!field || !field.name) return;

parts.push(`----${boundaryValue}`);
parts.push('Content-Disposition: form-data');

if (field.type === 'file') {
const filePaths = Array.isArray(field.value) ? field.value : (field.value ? [field.value] : ['']);
filePaths.forEach((filePath) => {
parts.push(`----${boundaryValue}`);
parts.push('Content-Disposition: form-data');
const fileName = getFileName(filePath);
parts.push(`name: ${field.name}`);
parts.push(`----${boundaryValue}`);
parts.push(`Content-Disposition: form-data; name: ${field.name}; filename: ${fileName}`);
if (field.contentType) parts.push(`Content-Type: ${field.contentType}`);
parts.push(`value: [File: ${fileName}]`);
parts.push('');
});
} else {
const value = formatValue(field.value);
parts.push(`name: ${field.name}`);
parts.push(`value: ${value}`);
parts.push('');
const values = Array.isArray(field.value) ? field.value : [field.value ?? ''];
values.forEach((val) => {
parts.push(`----${boundaryValue}`);
parts.push(`Content-Disposition: form-data; name: ${field.name}`);
if (field.contentType) parts.push(`Content-Type: ${field.contentType}`);
parts.push(`value: ${String(val ?? '')}`);
parts.push('');
});
}
});

parts.push(`----${boundaryValue}--`);
return parts.join('\n');
};

const createFormData = (data, collectionPath) => {
const createFormData = (data, collectionPath, streamThreshold = STREAMING_FILE_SIZE_THRESHOLD) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
const form = new FormData();
Expand All @@ -68,25 +65,33 @@ const createFormData = (data, collectionPath) => {
if (contentType) {
options.contentType = contentType;
}
if (type === 'text') {
if (Array.isArray(value)) {
value.forEach((val) => form.append(name, val, options));
} else {
form.append(name, value, options);
}
return;
}

if (type === 'file') {
const filePaths = value || [];
const filePaths = Array.isArray(value) ? value : (value ? [value] : []);
filePaths.forEach((filePath) => {
if (!filePath) return;
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);
try {
form.append(
name,
isLargeFile(trimmedFilePath, streamThreshold)
? fs.createReadStream(trimmedFilePath)
: fs.readFileSync(trimmedFilePath),
options
);
} catch (error) {
console.error('Error reading file:', error);
}
});
} else {
if (Array.isArray(value)) {
value.forEach((val) => form.append(name, val ?? '', options));
} else {
form.append(name, value ?? '', options);
}
}
});
return form;
Expand Down
42 changes: 42 additions & 0 deletions packages/bruno-electron/tests/utils/form-data.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('utils: formatMultipartData', () => {
const result = formatMultipartData(data, 'boundary');

expect(result).toContain('name: file');
expect(result).toContain('filename: Dumy.xml');
expect(result).toContain('value: [File: Dumy.xml]');
});

Expand All @@ -43,4 +44,45 @@ describe('utils: formatMultipartData', () => {
expect(formatMultipartData(data, '--boundary')).toContain('----boundary');
expect(formatMultipartData(data, 'boundary--')).toContain('----boundary');
});

test('should include per-part Content-Type when contentType is set on text field', () => {
const data = [{ name: 'message', type: 'text', value: '{"k":"v"}', contentType: 'application/json; charset=utf-8' }];
const result = formatMultipartData(data, 'boundary');

expect(result).toContain('Content-Type: application/json; charset=utf-8');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('should only include Content-Type for parts that define contentType', () => {
const data = [
{ name: 'json', type: 'text', value: '{"k":"v"}', contentType: 'application/json; charset=utf-8' },
{ name: 'plain', type: 'text', value: 'hello' }
];
const result = formatMultipartData(data, 'boundary');

expect(result).toContain('name: json');
expect(result).toContain('name: plain');
expect(result).toContain('Content-Type: application/json; charset=utf-8');
const contentTypeMatches = result.match(/Content-Type:/g) || [];
expect(contentTypeMatches.length).toBe(1);
});

test('should render array text values as separate parts', () => {
const data = [{ name: 'tag', type: 'text', value: ['a', 'b'] }];
const result = formatMultipartData(data, 'boundary');

const matches = result.match(/name: tag/g) || [];
expect(matches.length).toBe(2);
expect(result).toContain('value: a');
expect(result).toContain('value: b');
});

test('should render each file as a separate part with filename in disposition', () => {
const data = [{ name: 'attach', type: 'file', value: ['a.jpg', 'b.jpg'] }];
const result = formatMultipartData(data, 'boundary');

expect(result).toContain('filename: a.jpg');
expect(result).toContain('filename: b.jpg');
const matches = result.match(/name: attach/g) || [];
expect(matches.length).toBe(2);
});
});