fix: Consistent multipart form handling and @contentType support in examples (#6325)

* fix: multiline multipart items within multipart within response example

* change multiline  editor to single line fot contentType

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
This commit is contained in:
sanish chirayath
2025-12-11 01:17:39 +05:30
committed by GitHub
parent 1ed957978a
commit b61d2212f6
11 changed files with 240 additions and 41 deletions

View File

@@ -9,6 +9,7 @@ import {
} from 'providers/ReduxStore/slices/collections';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
@@ -178,7 +179,7 @@ const MultipartFormParams = ({ item, collection }) => {
placeholder: 'Auto',
width: '20%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<MultiLineEditor
<SingleLineEditor
onSave={onSave}
theme={storedTheme}
placeholder={isLastEmptyRow ? 'Auto' : ''}

View File

@@ -8,6 +8,7 @@ import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/s
import mime from 'mime-types';
import path from 'utils/common/path';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
import Table from 'components/Table-v2';
@@ -206,7 +207,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
</td>
<td>
<div className="flex items-center justify-center pl-4">
<MultiLineEditor
<SingleLineEditor
onSave={() => {}}
theme={storedTheme}
placeholder="Auto"

View File

@@ -11,6 +11,10 @@ const astBaseAttribute = {
},
pair(_1, key, _2, _3, _4, value, _5) {
let res = {};
if (Array.isArray(value.ast)) {
res[key.ast] = value.ast;
return res;
}
res[key.ast] = value.ast ? value.ast.trim() : '';
return res;
},
@@ -23,33 +27,6 @@ const astBaseAttribute = {
key(chars) {
return chars.sourceString ? chars.sourceString.trim() : '';
},
value(chars) {
if (chars.ctorName === 'list') {
return chars.ast;
}
try {
let isBacktickMultiline = chars.sourceString?.startsWith('```') && chars.sourceString?.endsWith('```');
if (isBacktickMultiline) {
const multilineString = chars.sourceString?.replace(/^```|```$/g, '');
return multilineString
.split('\n')
.map((line) => line.slice(4))
.join('\n');
}
let isQuoteMultiline = chars.sourceString?.startsWith(`'''`) && chars.sourceString?.endsWith(`'''`);
if (isQuoteMultiline) {
const multilineString = chars.sourceString?.replace(/^'''|'''$/g, '');
return multilineString
.split('\n')
.map((line) => line.slice(4))
.join('\n');
}
return chars.sourceString ? chars.sourceString.trim() : '';
} catch (err) {
console.error(err);
}
return chars.sourceString ? chars.sourceString.trim() : '';
},
textblock(line, _1, rest) {
return [line.ast, ...rest.ast].join('\n');
},
@@ -74,8 +51,19 @@ const astBaseAttribute = {
multilinetextblockdelimiter(_) {
return '';
},
multilinetextblock(_1, content, _2) {
return content.sourceString.trim();
multilinetextblock(_1, content, _2, _3, contentType) {
const multilineString = content.sourceString
.split('\n')
.map((line) => line.slice(4))
.join('\n');
if (!contentType.sourceString) {
return multilineString;
}
return `${multilineString} ${contentType.sourceString}`;
},
singlelinevalue(chars) {
return chars.sourceString?.trim() || '';
},
_iter(...elements) {
return elements.map((e) => e.ast);

View File

@@ -69,7 +69,7 @@ const mapRequestParams = (pairList = [], type) => {
*/
const multipartExtractContentType = (pair) => {
if (_.isString(pair.value)) {
const match = pair.value.match(/^(.*?)\s*@contentType\((.*?)\)\s*$/);
const match = pair.value.match(/^(.*?)\s*@contentType\((.*?)\)\s*$/s);
if (match != null && match.length > 2) {
pair.value = match[1];
pair.contentType = match[2];
@@ -85,7 +85,7 @@ const multipartExtractContentType = (pair) => {
*/
const fileExtractContentType = (pair) => {
if (_.isString(pair.value)) {
const match = pair.value.match(/^(.*?)\s*@contentType\((.*?)\)\s*$/);
const match = pair.value.match(/^(.*?)\s*@contentType\((.*?)\)\s*$/s);
if (match && match.length > 2) {
pair.value = match[1].trim();
pair.contentType = match[2].trim();

View File

@@ -25,7 +25,8 @@ const exampleGrammar = ohm.grammar(`Example {
// Multiline text block surrounded by '''
multilinetextblockdelimiter = "'''"
multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter
multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter st* contenttypeannotation?
contenttypeannotation = "@contentType(" (~")" any)* ")"
// Dictionary Blocks
dictionary = st* "{" pairlist? tagend
@@ -38,7 +39,8 @@ const exampleGrammar = ohm.grammar(`Example {
quoted_key_char = ~(quote_char | esc_quote_char | nl) any
quoted_key = disable_char? quote_char (esc_quote_char | quoted_key_char)* quote_char
key = keychar*
value = multilinetextblock | valuechar*
value = list | multilinetextblock | singlelinevalue
singlelinevalue = valuechar*
// List
list = st* "[" nl+ listitems? st* nl+ st* "]"

View File

@@ -1,4 +1,4 @@
const { indentString } = require('../utils');
const { indentString, getValueString } = require('../utils');
// remove the last line if two new lines are found
const stripLastLine = (text) => {
@@ -138,7 +138,9 @@ const jsonToExampleBru = (json) => {
= item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
if (item.type === 'text') {
return `${enabled}${quoteKey(item.name)}: ${item.value}${contentType}`;
// Use getValueString to wrap multiline values with triple quotes
const valueString = getValueString(item.value);
return `${enabled}${quoteKey(item.name)}: ${valueString}${contentType}`;
}
if (item.type === 'file') {

View File

@@ -29,7 +29,8 @@ const requestGrammar = ohm.grammar(`Request {
// Multiline text block surrounded by '''
multilinetextblockdelimiter = "'''"
multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter
multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter st* contenttypeannotation?
contenttypeannotation = "@contentType(" (~")" any)* ")"
// Dictionary Blocks
dictionary = st* "{" pairlist? tagend
@@ -42,7 +43,8 @@ const requestGrammar = ohm.grammar(`Request {
quoted_key_char = ~(quote_char | esc_quote_char | nl) any
quoted_key = disable_char? quote_char (esc_quote_char | quoted_key_char)* quote_char
key = keychar*
value = list | multilinetextblock | valuechar*
value = list | multilinetextblock | singlelinevalue
singlelinevalue = valuechar*
// List
list = st* "[" nl+ listitems? st* nl+ st* "]"

View File

@@ -23,7 +23,8 @@ const responseGrammar = ohm.grammar(`Response {
// Multiline text block surrounded by '''
multilinetextblockdelimiter = "'''"
multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter
multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter st* contenttypeannotation?
contenttypeannotation = "@contentType(" (~")" any)* ")"
// Dictionary Blocks
dictionary = st* "{" pairlist? tagend
@@ -36,7 +37,8 @@ const responseGrammar = ohm.grammar(`Response {
quoted_key_char = ~(quote_char | esc_quote_char | nl) any
quoted_key = disable_char? quote_char (esc_quote_char | quoted_key_char)* quote_char
key = keychar*
value = list | multilinetextblock | valuechar*
value = list | multilinetextblock | singlelinevalue
singlelinevalue = valuechar*
// List
list = st* "[" nl+ listitems? st* nl+ st* "]"

View File

@@ -308,4 +308,70 @@ example {
});
});
});
describe('Examples with multiline strings and contentType', () => {
it('should parse examples with multiline strings and @contentType annotations', () => {
const input = fs.readFileSync(path.join(__dirname, 'fixtures', 'bru', 'examples-multiline-contenttype.bru'), 'utf8');
const expected = require('./fixtures/json/examples-multiline-contenttype.json');
const output = bruToJson(input);
expect(output).toEqual(expected);
});
it('should correctly extract contentType from multiline values', () => {
const input = fs.readFileSync(path.join(__dirname, 'fixtures', 'bru', 'examples-multiline-contenttype.bru'), 'utf8');
const output = bruToJson(input);
const example = output.examples[0];
const multipartForm = example.request.body.multipartForm;
// Check that multiline values with @contentType are parsed correctly
const testField = multipartForm.find((f) => f.name === 'test');
expect(testField).toBeDefined();
expect(testField.value).toContain('"hello"');
expect(testField.contentType).toBe('application/json');
// Check single-line value with @contentType
const simpleField = multipartForm.find((f) => f.name === 'simple');
expect(simpleField).toBeDefined();
expect(simpleField.value).toBe('cat and mouse');
expect(simpleField.contentType).toBe('text/plain');
// Check multiline value without @contentType
const arrayField = multipartForm.find((f) => f.name === 'array');
expect(arrayField).toBeDefined();
expect(arrayField.value).toContain('"coolade"');
expect(arrayField.contentType).toBe('');
// Check complex multiline JSON with @contentType
const jsonValueField = multipartForm.find((f) => f.name === 'jsonValue');
expect(jsonValueField).toBeDefined();
expect(jsonValueField.value).toContain('"key": "value"');
expect(jsonValueField.contentType).toBe('application/json');
});
it('should handle round-trip conversion for multiline strings with contentType', () => {
const originalBru = fs.readFileSync(path.join(__dirname, 'fixtures', 'bru', 'examples-multiline-contenttype.bru'), 'utf8');
const jsonFromBru = bruToJson(originalBru);
const bruFromJson = jsonToBru(jsonFromBru);
const jsonFromBruAgain = bruToJson(bruFromJson);
// The examples should be preserved through the round-trip
expect(jsonFromBruAgain.examples).toBeDefined();
expect(Array.isArray(jsonFromBruAgain.examples)).toBe(true);
expect(jsonFromBruAgain.examples).toHaveLength(1);
const example = jsonFromBruAgain.examples[0];
const multipartForm = example.request.body.multipartForm;
// Verify contentType is preserved
const testField = multipartForm.find((f) => f.name === 'test');
expect(testField.contentType).toBe('application/json');
expect(testField.value).toContain('"hello"');
const jsonValueField = multipartForm.find((f) => f.name === 'jsonValue');
expect(jsonValueField.contentType).toBe('application/json');
expect(jsonValueField.value).toContain('"key": "value"');
});
});
});

View File

@@ -0,0 +1,65 @@
meta {
name: Multiline ContentType Test
type: http
seq: 1
}
post {
url: {{host}}/test
body: multipartForm
}
example {
name: Example with multiline strings and contentType
request: {
url: {{host}}/test
method: POST
mode: multipartForm
body:multipart-form: {
test: '''
{
"hello" : "there"
}
''' @contentType(application/json)
simple: cat and mouse @contentType(text/plain)
array: '''
[
"coolade",
"blast"
]
'''
jsonValue: '''
{
"key": "value",
"nested": {
"data": 123
}
}
''' @contentType(application/json)
textValue: '''
This is a
multiline text
value
''' @contentType(text/plain)
}
}
response: {
status: {
code: 200
text: OK
}
body: {
type: json
content: '''
{
"status": "success",
"message": "Data received"
}
'''
}
}
}

View File

@@ -0,0 +1,70 @@
{
"meta": {
"name": "Multiline ContentType Test",
"type": "http",
"seq": "1"
},
"http": {
"method": "post",
"url": "{{host}}/test",
"body": "multipartForm"
},
"examples": [
{
"name": "Example with multiline strings and contentType",
"request": {
"url": "{{host}}/test",
"method": "POST",
"body": {
"mode": "multipartForm",
"multipartForm": [
{
"name": "test",
"value": "{\n\"hello\" : \"there\"\n}",
"enabled": true,
"type": "text",
"contentType": "application/json"
},
{
"name": "simple",
"value": "cat and mouse",
"enabled": true,
"type": "text",
"contentType": "text/plain"
},
{
"name": "array",
"value": "[\n\"coolade\", \n\"blast\"\n]",
"enabled": true,
"type": "text",
"contentType": ""
},
{
"name": "jsonValue",
"value": "{\n \"key\": \"value\",\n \"nested\": {\n \"data\": 123\n }\n}",
"enabled": true,
"type": "text",
"contentType": "application/json"
},
{
"name": "textValue",
"value": "This is a\nmultiline text\nvalue",
"enabled": true,
"type": "text",
"contentType": "text/plain"
}
]
}
},
"response": {
"status": "200",
"statusText": "OK",
"body": {
"type": "json",
"content": "{\n \"status\": \"success\",\n \"message\": \"Data received\"\n}"
}
}
}
]
}