Compare commits

...

6 Commits
main ... v3.3.0

Author SHA1 Message Date
shubh-bruno
34460d5bcf fix: shortcut in query builder (#7812)
* fix: enter shortcut for query builder

* chore: remove comments

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-22 22:24:14 +05:30
shubh-bruno
422a43ce56 fix: shortcut for query builder (#7805)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-22 22:24:06 +05:30
shubh-bruno
2720ac20b4 fix: qol fixes for keybindings (#7709)
* fix: keybindings issues

* chore: let SingleLineEditor handle it's own handleSubmit

* fix: resolve issues

* fix: disable reset default if none are changed

* fix: exlude transient request from reopen last closed tabs

* fix: updated all hardcoded colors to respective theme colors

* chore: pick color from theme

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-22 22:23:27 +05:30
gopu-bruno
2e1c8b3382 fix: generate examples for description only responses in swagger 2.0 converter (#7717) 2026-04-22 22:23:10 +05:30
Sid
95fccbeb8d fix: avoid round trip loss of annotation data (#7730)
* fix: avoid round trip loss of annotation data

* feat: update types for file , multipart and tests for the same

* chore: optional

* chore: fix body:file annotation

* chore: remove log
2026-04-22 22:22:58 +05:30
shubh-bruno
e964bdc7fe fix: response filter in runner (#7747)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-22 22:22:50 +05:30
30 changed files with 669 additions and 99 deletions

View File

@@ -84,8 +84,8 @@ export default class CodeEditor extends React.Component {
this.searchBarRef.current?.focus();
});
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
'Cmd-H': this.props.readOnly ? false : 'replace',
'Ctrl-H': this.props.readOnly ? false : 'replace',
'Tab': function (cm) {
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
? cm.execCommand('indentMore')

View File

@@ -39,7 +39,6 @@ const StyledWrapper = styled.div`
.section-divider {
height: 1px;
background: ${(props) => props.theme.input.border};
margin: 10px 0;
}
.tables-container {
@@ -75,7 +74,7 @@ const StyledWrapper = styled.div`
thead {
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
background: ${(props) => props.theme.table.striped};
user-select: none;
td {
@@ -100,9 +99,8 @@ const StyledWrapper = styled.div`
tr {
transition: background 0.1s ease;
height: 30px;
td {
padding: 0 10px !important;
padding: 0px 10px !important;
border: none !important;
vertical-align: middle;
background: transparent;
@@ -111,7 +109,7 @@ const StyledWrapper = styled.div`
}
tr:hover:not(.row-editing) td {
background: ${(props) => props.theme.sidebar.bg};
background: ${(props) => props.theme.background.surface0};
cursor: pointer;
}
@@ -120,7 +118,7 @@ const StyledWrapper = styled.div`
}
tr.section-heading-row td {
font-weight: 600;
font-weight: 700;
padding: 6px 10px !important;
user-select: none;
}
@@ -131,8 +129,28 @@ const StyledWrapper = styled.div`
}
tr.section-last-row td {
border-bottom: none !important;
}
tr.section-spacer-row {
height: 8px;
pointer-events: none;
}
tr.section-spacer-row td {
padding: 0 !important;
height: 8px;
line-height: 8px;
font-size: 0;
background: transparent !important;
border: none !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
}
tr.section-spacer-row:hover td {
background: transparent !important;
cursor: default;
}
}
.keybinding-row {
@@ -180,7 +198,7 @@ const StyledWrapper = styled.div`
}
.shortcut-input--editing {
outline: 1px solid #E4AE49;
outline: 1px solid ${(props) => props.theme.status.warning.border};
border-radius: 4px;
min-width: 100%;
max-width: 100%;
@@ -189,7 +207,7 @@ const StyledWrapper = styled.div`
}
.shortcut-input--error.shortcut-input--editing {
outline: 1px solid #CE4F3B;
outline: 1px solid ${(props) => props.theme.status.danger.border};
min-width: 100%;
max-width: 100%;
}
@@ -220,39 +238,41 @@ const StyledWrapper = styled.div`
align-items: center;
justify-content: center;
min-width: 20px;
height: 22px;
height: 20px;
padding: 2px;
border-radius: 3px;
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.input.color};
font-size: 12px;
font-size: 10px;
font-weight: 500;
line-height: 1;
}
tbody tr.row-success td {
background: #2E8A540F;
tbody tr.row-success td,
tbody tr.row-success:hover td {
background: ${(props) => props.theme.status.success.background} !important;
}
tbody tr.row-error td {
background: #D32F2F0F;
tbody tr.row-error td,
tbody tr.row-error:hover td {
background: ${(props) => props.theme.status.danger.background} !important;
}
.success-icon {
color: #2E8A54;
color: ${(props) => props.theme.status.success.text};
display: inline-flex;
align-items: center;
}
.error-icon {
color: #CE4F3B;
color: ${(props) => props.theme.status.danger.text};
display: inline-flex;
align-items: center;
}
.input-error-icon {
color: #CE4F3B;
color: ${(props) => props.theme.status.danger.text};
display: inline-flex;
align-items: center;
margin-left: auto;
@@ -294,6 +314,11 @@ const StyledWrapper = styled.div`
border-radius: 6px;
padding: 0px 6px;
cursor: pointer;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.action-btn {

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState, useEffect } from 'react';
import React, { useMemo, useRef, useState, useEffect, Fragment } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -10,6 +10,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js';
import { Tooltip } from 'react-tooltip';
import ToggleSwitch from 'components/ToggleSwitch/index';
import toast from 'react-hot-toast';
const SEP = '+bind+';
const getOS = () => (isMacOS() ? 'mac' : 'windows');
@@ -82,10 +83,10 @@ const renderDisplayValue = (displayValue, os) => {
return (
<span className="shortcut-pills">
{parsed.map((keysArr, index) => (
<React.Fragment key={index}>
<Fragment key={index}>
{index > 0 && <span className="shortcut-separator"> - </span>}
{renderKeycaps(keysArr, os)}
</React.Fragment>
</Fragment>
))}
</span>
);
@@ -218,23 +219,21 @@ const RESERVED_BY_OS = {
comboSignature(['f12']) // Dashboard (older macOS)
]),
windows: new Set([
// System-level shortcuts (intercepted by Windows before reaching the app)
comboSignature(['alt', 'tab']),
comboSignature(['alt', 'shift', 'tab']),
comboSignature(['alt', 'f4']),
comboSignature(['f1']), // Windows Help
comboSignature(['alt', 'esc']),
comboSignature(['alt', 'space']),
comboSignature(['ctrl', 'alt', 'delete']),
comboSignature(['command', 'l']),
comboSignature(['command', 'd']),
comboSignature(['command', 'e']),
comboSignature(['command', 'r']),
comboSignature(['command', 'i']),
comboSignature(['command', 's']),
comboSignature(['command', 'a']),
comboSignature(['command', 'x']),
comboSignature(['command', 'm']),
comboSignature(['command', 'tab']),
comboSignature(['ctrl', 'shift', 'esc']),
// Function keys
comboSignature(['f1']), // Windows Help
comboSignature(['f11']), // Fullscreen toggle
comboSignature(['f12']), // DevTools
// Undo/Redo - standard text editing shortcuts that browsers handle natively
comboSignature(['ctrl', 'z']),
comboSignature(['ctrl', 'y']),
comboSignature(['ctrl', 'shift', 'z']),
// Toggle Developer Tools
comboSignature(['ctrl', 'shift', 'i'])
@@ -493,7 +492,7 @@ const Keybindings = () => {
if (buildUsedSignatures(action).has(sig)) {
return {
code: ERROR.DUPLICATE,
message: 'That shortcut is already in use.'
message: 'This shortcut is already in use.'
};
}
@@ -562,9 +561,24 @@ const Keybindings = () => {
return next;
});
persistToPreferences(action, def);
// Remove the entry from user preferences entirely so falls back to default.
// This also keeps `hasCustomizedKeybindings` accurate.
const nextKeyBindings = { ...(preferences?.keyBindings || {}) };
delete nextKeyBindings[action];
const updatedPreferences = {
...preferences,
keyBindings: nextKeyBindings
};
dispatch(savePreferences(updatedPreferences));
};
const hasCustomizedKeybindings = useMemo(() => {
const userKeyBindings = preferences?.keyBindings || {};
return Object.keys(userKeyBindings).length > 0;
}, [preferences?.keyBindings]);
const resetAllKeybindings = () => {
const updatedPreferences = {
...preferences,
@@ -572,6 +586,7 @@ const Keybindings = () => {
};
dispatch(savePreferences(updatedPreferences));
toast.success('All shortcuts have been reset to default');
};
const startEditing = (action) => {
@@ -799,6 +814,7 @@ const Keybindings = () => {
onClick={resetAllKeybindings}
className="reset-btn"
data-testid="reset-all-keybindings-btn"
disabled={!hasCustomizedKeybindings}
>
Reset Default
</button>
@@ -817,7 +833,7 @@ const Keybindings = () => {
</thead>
<tbody>
{groupedKeyMappings.map((section, sectionIndex) => (
<React.Fragment key={section.heading}>
<Fragment key={section.heading}>
<tr className="section-heading-row">
<td colSpan={2}>{section.heading}</td>
</tr>
@@ -946,7 +962,12 @@ const Keybindings = () => {
</tr>
);
})}
</React.Fragment>
{sectionIndex < groupedKeyMappings.length - 1 && (
<tr className="section-spacer-row" aria-hidden="true">
<td colSpan={2}>&nbsp;</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>

View File

@@ -94,6 +94,7 @@ const ArgValueInput = ({ value, onChange, field }) => {
onChange={(e) => onChange(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="Enter value"
className="mousetrap"
/>
);
};
@@ -139,7 +140,7 @@ const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues,
)}
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isEnabled}
onChange={(e) => {
e.stopPropagation();
@@ -230,12 +231,6 @@ const FieldNode = ({
role="treeitem"
aria-expanded={isExpanded}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
@@ -248,7 +243,7 @@ const FieldNode = ({
</span>
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
@@ -268,12 +263,6 @@ const FieldNode = ({
role="treeitem"
aria-expanded={canExpand ? isExpanded : undefined}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
@@ -288,7 +277,7 @@ const FieldNode = ({
</span>
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
@@ -315,7 +304,7 @@ const FieldNode = ({
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
@@ -369,7 +358,7 @@ const FieldNode = ({
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
@@ -419,12 +408,6 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
@@ -438,7 +421,7 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
</span>
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
@@ -486,12 +469,6 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
@@ -505,7 +482,7 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
</span>
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}

View File

@@ -211,7 +211,12 @@ const StyledWrapper = styled.div`
padding: 3px 8px;
font-size: 13px;
min-width: 0;
cursor: default;
cursor: pointer;
&:hover,
&:focus-visible {
background: ${(props) => props.theme.background.surface0};
}
.input-object-chevron {
width: 14px;

View File

@@ -175,6 +175,7 @@ const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, v
type="text"
placeholder="Search operations..."
value={searchText}
className="mousetrap"
onChange={(e) => setSearchText(e.target.value)}
/>
</div>

View File

@@ -137,6 +137,12 @@ export default class QueryEditor extends React.Component {
this.addOverlay();
setupLinkAware(editor);
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
const cmInput = editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
}
componentDidUpdate(prevProps) {

View File

@@ -225,7 +225,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
if (tab.type === 'environment-settings') {
if (collection?.environmentsDraft) {
const { environmentUid, variables } = collection.environmentsDraft;
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
if (environmentUid?.startsWith('dotenv:')) {
window.dispatchEvent(new Event('dotenv-save'));
} else {
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
}
}
} else if (tab.type === 'global-environment-settings') {
if (globalEnvironmentDraft) {

View File

@@ -19,6 +19,8 @@ const QueryResponse = ({
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
const [selectedFormat, setSelectedFormat] = useState('raw');
const [selectedTab, setSelectedTab] = useState('editor');
const [filter, setFilter] = useState('');
const [filterExpanded, setFilterExpanded] = useState(false);
useEffect(() => {
if (initialFormat !== null && initialTab !== null) {
@@ -56,6 +58,10 @@ const QueryResponse = ({
error={error}
selectedFormat={selectedFormat}
selectedTab={selectedTab}
filter={filter}
filterExpanded={filterExpanded}
onFilterChange={setFilter}
onFilterExpandChange={setFilterExpanded}
/>
</div>
</StyledWrapper>

View File

@@ -316,18 +316,6 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
<form
className="bruno-form"
onSubmit={formik.handleSubmit}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.defaultPrevented) {
const isTextInput
= ['input', 'textarea'].includes(e.target.tagName.toLowerCase())
|| e.target.isContentEditable;
if (!isTextInput) {
e.preventDefault();
formik.handleSubmit();
}
}
}}
>
<div>
<label htmlFor="requestName" className="block font-medium">
@@ -523,6 +511,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
className="flex px-2 items-center flex-grow input-container h-full min-w-0"
>
<SingleLineEditor
onRun={() => formik.handleSubmit()}
onPaste={handlePaste}
placeholder="Request URL"
value={formik.values.requestUrl || ''}

View File

@@ -35,7 +35,8 @@ taskMiddleware.startListening({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item),
preview: task?.preview ?? true
preview: task?.preview ?? true,
...(item.isTransient ? { isTransient: true } : {})
})
);
}

View File

@@ -1079,11 +1079,12 @@ export const collectionsSlice = createSlice({
item.draft = cloneDeep(item);
}
const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || [];
const newQueryParams = map(params, ({ uid, name = '', value = '', description = '', type = 'query', enabled = true }) => ({
const newQueryParams = map(params, ({ uid, name = '', value = '', description = '', annotations = null, type = 'query', enabled = true }) => ({
uid: uid || uuid(),
name,
value,
description,
annotations,
type,
enabled
}));
@@ -1325,11 +1326,12 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.headers = map(action.payload.headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
item.draft.request.headers = map(action.payload.headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
uid: uid || uuid(),
name,
value,
description,
annotations,
enabled
}));
},
@@ -1353,11 +1355,12 @@ export const collectionsSlice = createSlice({
collection.draft.root.request = {};
}
collection.draft.root.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
collection.draft.root.request.headers = map(headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
uid: uid || uuid(),
name,
value,
description,
annotations,
enabled
}));
},
@@ -1380,11 +1383,12 @@ export const collectionsSlice = createSlice({
if (!folder.draft.request) {
folder.draft.request = {};
}
folder.draft.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
folder.draft.request.headers = map(headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
uid: uid || uuid(),
name,
value,
description,
annotations,
enabled
}));
},

View File

@@ -22,7 +22,7 @@ export const tabsSlice = createSlice({
initialState,
reducers: {
addTab: (state, action) => {
const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid } = action.payload;
const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid, isTransient } = action.payload;
const nonReplaceableTabTypes = [
'variables',
@@ -75,7 +75,8 @@ export const tabsSlice = createSlice({
: !nonReplaceableTabTypes.includes(type),
...(uid ? { folderUid: uid } : {}),
...(exampleUid ? { exampleUid } : {}),
...(itemUid ? { itemUid } : {})
...(itemUid ? { itemUid } : {}),
...(isTransient ? { isTransient: true } : {})
};
state.activeTabUid = uid;
@@ -103,7 +104,8 @@ export const tabsSlice = createSlice({
? preview
: !nonReplaceableTabTypes.includes(type),
...(exampleUid ? { exampleUid } : {}),
...(itemUid ? { itemUid } : {})
...(itemUid ? { itemUid } : {}),
...(isTransient ? { isTransient: true } : {})
});
state.activeTabUid = uid;
},
@@ -270,8 +272,9 @@ export const tabsSlice = createSlice({
const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
// Push closed tabs onto the recently closed stack (LIFO)
// Exclude transient requests — they have no persisted file and can't be reopened
const closedTabs = state.tabs.filter((t) =>
tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type)
tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type) && !t.isTransient
);
if (closedTabs.length > 0) {
state.recentlyClosedTabs.push(...closedTabs);

View File

@@ -181,6 +181,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
};
});
@@ -193,6 +194,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
name: param.name,
value: param.value,
description: param.description,
annotations: param.annotations,
type: param.type,
enabled: param.enabled
};
@@ -745,6 +747,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
name: param.name,
value: param.value,
description: param.description,
annotations: param.annotations,
type: param.type,
enabled: param.enabled
});
@@ -757,6 +760,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
});
});
@@ -813,6 +817,7 @@ export const transformCollectionRootToSave = (collection) => {
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
});
});
@@ -843,6 +848,7 @@ export const transformFolderRootToSave = (folder) => {
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
});
});

View File

@@ -1,5 +1,5 @@
const { describe, it, expect } = require('@jest/globals');
import { mergeHeaders } from './index';
import { mergeHeaders, transformRequestToSaveToFilesystem } from './index';
describe('mergeHeaders', () => {
it('should include headers from collection, folder and request (with correct precedence)', () => {
@@ -35,3 +35,54 @@ describe('mergeHeaders', () => {
expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request']));
});
});
describe('transformRequestToSaveToFilesystem', () => {
it('preserves header and param annotations', () => {
const item = {
uid: 'requestuid123456789012',
type: 'http-request',
name: 'Annotated Request',
seq: 1,
settings: {},
tags: [],
examples: [],
request: {
method: 'GET',
url: 'https://example.com',
params: [
{
uid: 'paramuid1234567890123',
name: 'q',
value: '1',
description: '',
annotations: [{ name: 'param-note', value: 'keep me' }],
type: 'query',
enabled: true
}
],
headers: [
{
uid: 'headeruid123456789012',
name: 'X-Test',
value: '1',
description: '',
annotations: [{ name: 'header-note', value: 'keep me' }],
enabled: true
}
],
auth: { mode: 'none' },
body: { mode: 'none' },
script: { req: '', res: '' },
vars: { req: [], res: [] },
assertions: [],
tests: '',
docs: ''
}
};
const transformed = transformRequestToSaveToFilesystem(item);
expect(transformed.request.params[0].annotations).toEqual([{ name: 'param-note', value: 'keep me' }]);
expect(transformed.request.headers[0].annotations).toEqual([{ name: 'header-note', value: 'keep me' }]);
});
});

View File

@@ -322,6 +322,18 @@ const transformSwaggerRequestItem = (request, usedNames = new Set(), options = {
requestBodySchema,
requestBodyContentType
}));
} else if (response.description) {
// description only (e.g., 204 No Content) — create example without body
examples.push(createBrunoExample({
brunoRequestItem,
exampleValue: '',
exampleName: `${statusCode} Response`,
exampleDescription: response.description,
statusCode,
contentType: null,
requestBodySchema,
requestBodyContentType
}));
}
});

View File

@@ -209,7 +209,7 @@ describe('swagger2-to-bruno response examples', () => {
expect(req.examples[0].response.status).toBe(200);
});
it('should not generate examples when responses have no schema or examples', () => {
it('should generate examples from description only responses', () => {
const spec = {
swagger: '2.0',
info: { title: 'No Schema API', version: '1.0' },
@@ -229,8 +229,20 @@ describe('swagger2-to-bruno response examples', () => {
const collection = swagger2ToBruno(spec);
const req = collection.items.find((i) => i.name === 'Delete data');
// No schema or examples → no examples array
expect(req.examples).toBeUndefined();
expect(req.examples).toBeDefined();
expect(req.examples.length).toBe(2);
const noContentExample = req.examples.find((e) => e.response.status === 204);
expect(noContentExample).toBeDefined();
expect(noContentExample.name).toBe('204 Response');
expect(noContentExample.description).toBe('No Content');
expect(noContentExample.response.body.content).toBe('');
expect(noContentExample.response.headers).toEqual([]);
const notFoundExample = req.examples.find((e) => e.response.status === 404);
expect(notFoundExample).toBeDefined();
expect(notFoundExample.name).toBe('404 Response');
expect(notFoundExample.description).toBe('Not Found');
});
it('should set correct statusText in response examples', () => {

View File

@@ -645,6 +645,7 @@ const transformRequestToSaveToFilesystem = (item) => {
name: param.name,
value: param.value,
description: param.description,
annotations: param.annotations,
type: param.type,
enabled: param.enabled
});
@@ -657,6 +658,7 @@ const transformRequestToSaveToFilesystem = (item) => {
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
});
});

View File

@@ -20,6 +20,7 @@ describe('transformRequestToSaveToFilesystem', () => {
name: 'param1',
value: 'value1',
description: 'Test parameter',
annotations: [{ name: 'note', value: 'param annotation' }],
type: 'text',
enabled: true
}
@@ -30,6 +31,7 @@ describe('transformRequestToSaveToFilesystem', () => {
name: 'Content-Type',
value: 'application/json',
description: 'Request content type',
annotations: [{ name: 'note', value: 'header annotation' }],
enabled: true
}
],
@@ -101,6 +103,7 @@ describe('transformRequestToSaveToFilesystem', () => {
name: 'param1',
value: 'value1',
description: 'Test parameter',
annotations: [{ name: 'note', value: 'param annotation' }],
type: 'text',
enabled: true
});
@@ -112,6 +115,7 @@ describe('transformRequestToSaveToFilesystem', () => {
name: 'Content-Type',
value: 'application/json',
description: 'Request content type',
annotations: [{ name: 'note', value: 'header annotation' }],
enabled: true
});
});

View File

@@ -585,10 +585,11 @@ ${indentString(body.sparql)}
const selected = item.selected ? '' : '~';
const contentType
= item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
const annotPrefix = serializeAnnotations(item.annotations);
const filePath = item.filePath || '';
const value = `@file(${filePath})`;
const itemName = 'file';
return `${selected}${itemName}: ${value}${contentType}`;
return `${annotPrefix}${selected}${itemName}: ${value}${contentType}`;
})
.join('\n')
)}`;

View File

@@ -320,6 +320,35 @@ headers {
expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: '{{baseUrl}}/path' }]);
});
it('serializeAnnotations — annotation on params:path', () => {
const json = {
params: [{ name: 'userId', value: '123', enabled: true, type: 'path', annotations: [{ name: 'description', value: 'user id' }] }]
};
const bru = jsonToBru(json);
expect(bru).toContain('params:path {');
expect(bru).toContain('@description(\'user id\')\n userId: 123');
});
it('serializeAnnotations — annotation on metadata', () => {
const json = {
metadata: [{ name: 'trace-id', value: 'abc123', enabled: true, annotations: [{ name: 'description', value: 'trace id' }] }]
};
const bru = jsonToBru(json);
expect(bru).toContain('metadata {');
expect(bru).toContain('@description(\'trace id\')\n trace-id: abc123');
});
it('serializeAnnotations — annotation on body:form-urlencoded', () => {
const json = {
body: {
formUrlEncoded: [{ name: 'username', value: 'alice', enabled: true, annotations: [{ name: 'description', value: 'username field' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('body:form-urlencoded {');
expect(bru).toContain('@description(\'username field\')\n username: alice');
});
it('annotation on params:query block', () => {
const input = `
params:query {
@@ -333,6 +362,45 @@ params:query {
]);
});
it('annotation on params:path block', () => {
const input = `
params:path {
@description('user id')
userId: 123
}
`;
const output = parser(input);
expect(output.params).toEqual([
{ name: 'userId', value: '123', enabled: true, type: 'path', annotations: [{ name: 'description', value: 'user id' }] }
]);
});
it('annotation on metadata block', () => {
const input = `
metadata {
@description('trace id')
trace-id: abc123
}
`;
const output = parser(input);
expect(output.metadata).toEqual([
{ name: 'trace-id', value: 'abc123', enabled: true, annotations: [{ name: 'description', value: 'trace id' }] }
]);
});
it('annotation on body:form-urlencoded block', () => {
const input = `
body:form-urlencoded {
@description('username field')
username: alice
}
`;
const output = parser(input);
expect(output.body.formUrlEncoded).toEqual([
{ name: 'username', value: 'alice', enabled: true, annotations: [{ name: 'description', value: 'username field' }] }
]);
});
it('annotation on vars:pre-request block', () => {
const input = `
vars:pre-request {
@@ -352,6 +420,225 @@ vars:pre-request {
]);
});
it('annotation on vars:post-response block', () => {
const input = `
vars:post-response {
@description('auth token')
token: abc123
}
`;
const output = parser(input);
expect(output.vars.res).toEqual([
{
name: 'token',
value: 'abc123',
enabled: true,
local: false,
annotations: [{ name: 'description', value: 'auth token' }]
}
]);
});
it('annotation on local vars:pre-request pair', () => {
const input = `
vars:pre-request {
@description('local base url')
@BASE_URL: http://localhost
}
`;
const output = parser(input);
expect(output.vars.req).toEqual([
{
name: 'BASE_URL',
value: 'http://localhost',
enabled: true,
local: true,
annotations: [{ name: 'description', value: 'local base url' }]
}
]);
});
it('annotation on local vars:post-response pair', () => {
const input = `
vars:post-response {
@description('local token')
@token: abc123
}
`;
const output = parser(input);
expect(output.vars.res).toEqual([
{
name: 'token',
value: 'abc123',
enabled: true,
local: true,
annotations: [{ name: 'description', value: 'local token' }]
}
]);
});
it('annotation on body:multipart-form text field', () => {
const input = `
body:multipart-form {
@description('plain field')
field: value @contentType(text/plain)
}
`;
const output = parser(input);
expect(output.body.multipartForm).toEqual([
{
name: 'field',
value: 'value',
enabled: true,
type: 'text',
contentType: 'text/plain',
annotations: [{ name: 'description', value: 'plain field' }]
}
]);
});
it('annotation on body:multipart-form file field', () => {
const input = `
body:multipart-form {
@description('upload image')
upload: @file(/tmp/a.png|/tmp/b.png) @contentType(image/png)
}
`;
const output = parser(input);
expect(output.body.multipartForm).toEqual([
{
name: 'upload',
value: ['/tmp/a.png', '/tmp/b.png'],
enabled: true,
type: 'file',
contentType: 'image/png',
annotations: [{ name: 'description', value: 'upload image' }]
}
]);
});
it('annotation on body:file', () => {
const input = `
body:file {
@description('upload doc')
file: @file(/tmp/readme.pdf) @contentType(application/pdf)
}
`;
const output = parser(input);
expect(output.body.file).toEqual([
{
filePath: '/tmp/readme.pdf',
selected: true,
contentType: 'application/pdf',
annotations: [{ name: 'description', value: 'upload doc' }]
}
]);
});
it('serializeAnnotations — multipart text field with contentType', () => {
const json = {
body: {
multipartForm: [
{
name: 'field',
value: 'value',
enabled: true,
type: 'text',
contentType: 'text/plain',
annotations: [{ name: 'description', value: 'plain field' }]
}
]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('@description(\'plain field\')\n field: value @contentType(text/plain)');
});
it('serializeAnnotations — multipart file field with contentType', () => {
const json = {
body: {
multipartForm: [
{
name: 'upload',
value: ['/tmp/a.png', '/tmp/b.png'],
enabled: true,
type: 'file',
contentType: 'image/png',
annotations: [{ name: 'description', value: 'upload image' }]
}
]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('@description(\'upload image\')\n upload: @file(/tmp/a.png|/tmp/b.png) @contentType(image/png)');
});
it('serializeAnnotations — annotation on vars:post-response', () => {
const json = {
vars: {
res: [{ name: 'token', value: 'abc123', enabled: true, local: false, annotations: [{ name: 'description', value: 'auth token' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('vars:post-response {');
expect(bru).toContain('@description(\'auth token\')\n token: abc123');
});
it('serializeAnnotations — annotation on local vars:pre-request', () => {
const json = {
vars: {
req: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: true, annotations: [{ name: 'description', value: 'local base url' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('vars:pre-request {');
expect(bru).toContain('@description(\'local base url\')\n @BASE_URL: http://localhost');
});
it('serializeAnnotations — annotation on disabled local vars:post-response', () => {
const json = {
vars: {
res: [{ name: 'token', value: 'abc123', enabled: false, local: true, annotations: [{ name: 'description', value: 'local token' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('vars:post-response {');
expect(bru).toContain('@description(\'local token\')\n ~@token: abc123');
});
it('serializeAnnotations — body:file with annotations', () => {
const json = {
body: {
file: [{ filePath: '/tmp/readme.pdf', selected: true, contentType: 'application/pdf', annotations: [{ name: 'description', value: 'upload doc' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('body:file {');
expect(bru).toContain('@description(\'upload doc\')\n file: @file(/tmp/readme.pdf) @contentType(application/pdf)');
const parsed = parser(bru);
expect(parsed.body.file).toEqual(json.body.file);
});
it('roundtrip — multipart annotation survives json→bru→json', () => {
const json = {
body: {
multipartForm: [
{
name: 'upload',
value: ['/tmp/a.png'],
enabled: true,
type: 'file',
contentType: 'image/png',
annotations: [{ name: 'description', value: 'upload image' }]
}
]
}
};
const bru = jsonToBru(json);
const parsed = parser(bru);
expect(parsed.body.multipartForm).toEqual(json.body.multipartForm);
});
it('roundtrip: bru → json → bru → json equal', () => {
const input = `get {
url: https://example.com
@@ -792,6 +1079,39 @@ describe('collection pair annotations', () => {
expect(bru).toContain('@description(\'base url\')\n BASE_URL: http://localhost');
});
it('serializeAnnotations in jsonToCollectionBru — vars:post-response with annotation', () => {
const json = {
vars: {
res: [{ name: 'token', value: 'abc123', enabled: true, local: false, annotations: [{ name: 'description', value: 'auth token' }] }]
}
};
const bru = jsonToCollectionBru(json);
expect(bru).toContain('vars:post-response {');
expect(bru).toContain('@description(\'auth token\')\n token: abc123');
});
it('serializeAnnotations in jsonToCollectionBru — local vars:pre-request with annotation', () => {
const json = {
vars: {
req: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: true, annotations: [{ name: 'description', value: 'local base url' }] }]
}
};
const bru = jsonToCollectionBru(json);
expect(bru).toContain('vars:pre-request {');
expect(bru).toContain('@description(\'local base url\')\n @BASE_URL: http://localhost');
});
it('serializeAnnotations in jsonToCollectionBru — disabled local vars:post-response with annotation', () => {
const json = {
vars: {
res: [{ name: 'token', value: 'abc123', enabled: false, local: true, annotations: [{ name: 'description', value: 'local token' }] }]
}
};
const bru = jsonToCollectionBru(json);
expect(bru).toContain('vars:post-response {');
expect(bru).toContain('@description(\'local token\')\n ~@token: abc123');
});
it('parseAndSerialise - bru sourced roundtrip check - collection headers', () => {
const input = `headers {
@description('content type')

View File

@@ -1,4 +1,4 @@
import type { UID } from '../common';
import type { UID, Annotation } from '../common';
export interface EnvironmentVariable {
uid: UID;
@@ -7,6 +7,7 @@ export interface EnvironmentVariable {
type: 'text';
enabled?: boolean;
secret?: boolean;
annotations?: Annotation[] | null;
}
export interface Environment {

View File

@@ -0,0 +1,7 @@
/**
* Annotation applied to pairs (headers, vars, params, etc.)
*/
export interface Annotation {
name: string;
value?: string | null;
}

View File

@@ -1,3 +1,4 @@
import { Annotation } from './annotation';
import type { UID } from './uid';
export interface FileEntry {
@@ -5,6 +6,7 @@ export interface FileEntry {
filePath?: string | null;
contentType?: string | null;
selected: boolean;
annotations?: Annotation[];
}
export type FileList = FileEntry[];

View File

@@ -1,6 +1,7 @@
export type { UID } from './uid';
export type { KeyValue } from './key-value';
export type { Variable, Variables } from './variables';
export type { Annotation } from './annotation';
export type { MultipartFormEntry, MultipartForm } from './multipart-form';
export type { FileEntry, FileList } from './file';
export type { GraphqlBody } from './graphql';

View File

@@ -1,3 +1,4 @@
import { Annotation } from './annotation';
import type { UID } from './uid';
/**
@@ -9,4 +10,5 @@ export interface KeyValue {
value?: string | null;
description?: string | null;
enabled?: boolean;
annotations?: Annotation[] | null;
}

View File

@@ -1,3 +1,4 @@
import { Annotation } from './annotation';
import type { UID } from './uid';
export interface MultipartFormEntry {
@@ -8,6 +9,7 @@ export interface MultipartFormEntry {
description?: string | null;
contentType?: string | null;
enabled?: boolean;
annotations?: Annotation[];
}
export type MultipartForm = MultipartFormEntry[];

View File

@@ -1,3 +1,4 @@
import { Annotation } from './annotation';
import type { UID } from './uid';
/**
@@ -10,6 +11,7 @@ export interface Variable {
description?: string | null;
enabled?: boolean;
local?: boolean;
annotations?: Annotation[] | null;
}
export type Variables = Variable[] | null;

View File

@@ -0,0 +1,53 @@
const { itemSchema, environmentSchema, collectionSchema } = require('./index');
describe('annotation acceptance', () => {
test('itemSchema accepts annotations on headers and params', async () => {
const item = {
uid: 'aaaaaaaaaaaaaaaaaaaaa',
type: 'http-request',
name: 'Req',
request: {
url: 'https://example.com',
method: 'GET',
headers: [
{ uid: 'bbbbbbbbbbbbbbbbbbbbb', name: 'X-Test', value: '1', annotations: [{ name: 'note', value: 'header note' }] }
],
params: [
{ uid: 'ccccccccccccccccccccc', name: 'q', value: '1', type: 'query', annotations: [{ name: 'hint' }] }
],
},
};
await expect(itemSchema.validate(item)).resolves.toBeTruthy();
});
test('environmentSchema accepts annotations on variables', async () => {
const env = {
uid: 'ddddddddddddddddddddd',
name: 'Env',
variables: [
{ uid: 'eeeeeeeeeeeeeeeeeeeee', name: 'API_KEY', value: 'abc', annotations: [{ name: 'secret', value: null }], type: 'text', enabled: true, secret: false }
]
};
await expect(environmentSchema.validate(env)).resolves.toBeTruthy();
});
test('collectionSchema accepts annotations in item vars and items', async () => {
const coll = {
version: '1',
uid: 'fffffffffffffffffffff',
name: 'Coll',
items: [
{
uid: 'ggggggggggggggggggggg',
type: 'http-request',
name: 'Req2',
request: { url: '/path', method: 'POST', headers: [], params: [], vars: { req: [{ uid: 'hhhhhhhhhhhhhhhhhhhhh', name: 'base', value: 'https://example.com', annotations: [{ name: 'base-note' }] }] } }
}
]
};
await expect(collectionSchema.validate(coll)).resolves.toBeTruthy();
});
});

View File

@@ -1,11 +1,22 @@
const Yup = require('yup');
const { uidSchema } = require('../common');
const annotationSchema = Yup.object({
name: Yup.string().min(1).required('annotation name is required'),
value: Yup.string().nullable()
}).noUnknown(true)
.strict();
const environmentVariablesSchema = Yup.object({
uid: uidSchema,
name: Yup.string().nullable(),
// Allow mixed types (string, number, boolean, object) to support setting non-string values via scripts.
value: Yup.mixed().nullable(),
annotations: Yup.array()
.of(
annotationSchema
)
.nullable(),
type: Yup.string().oneOf(['text']).required('type is required'),
enabled: Yup.boolean().defined(),
secret: Yup.boolean()
@@ -29,6 +40,11 @@ const keyValueSchema = Yup.object({
name: Yup.string().nullable(),
value: Yup.string().nullable(),
description: Yup.string().nullable(),
annotations: Yup.array()
.of(
annotationSchema
)
.nullable(),
enabled: Yup.boolean()
})
.noUnknown(true)
@@ -79,6 +95,12 @@ const varsSchema = Yup.object({
name: Yup.string().nullable(),
value: Yup.string().nullable(),
description: Yup.string().nullable(),
// Optional annotations on variables
annotations: Yup.array()
.of(
annotationSchema
)
.nullable(),
enabled: Yup.boolean(),
// todo
@@ -109,6 +131,17 @@ const multipartFormSchema = Yup.object({
then: Yup.array().of(Yup.string().nullable()).nullable(),
otherwise: Yup.string().nullable()
}),
// Optional annotations on multipart entries
annotations: Yup.array()
.of(
Yup.object({
name: Yup.string().min(1).required('annotation name is required'),
value: Yup.string().nullable()
})
.noUnknown(true)
.strict()
)
.nullable(),
description: Yup.string().nullable(),
contentType: Yup.string().nullable(),
enabled: Yup.boolean()
@@ -126,6 +159,16 @@ const fileSchema = Yup.object({
.noUnknown(true)
.strict();
// Add annotations to file entries (when parsed from body:file blocks they can have @contentType only currently,
// but adding annotations ensures roundtrip validation doesn't fail if annotations are present in future)
const fileSchemaWithAnnotations = fileSchema.shape({
annotations: Yup.array()
.of(
annotationSchema
)
.nullable()
});
const requestBodySchema = Yup.object({
mode: Yup.string()
.oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql', 'file'])
@@ -137,7 +180,7 @@ const requestBodySchema = Yup.object({
formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
multipartForm: Yup.array().of(multipartFormSchema).nullable(),
graphql: graphqlBodySchema.nullable(),
file: Yup.array().of(fileSchema).nullable()
file: Yup.array().of(fileSchemaWithAnnotations).nullable()
})
.noUnknown(true)
.strict();
@@ -378,6 +421,12 @@ const requestParamsSchema = Yup.object({
name: Yup.string().nullable(),
value: Yup.string().nullable(),
description: Yup.string().nullable(),
// Optional annotations on params
annotations: Yup.array()
.of(
annotationSchema
)
.nullable(),
type: Yup.string().oneOf(['query', 'path']).required('type is required'),
enabled: Yup.boolean()
})
@@ -649,5 +698,6 @@ module.exports = {
itemSchema,
environmentSchema,
environmentsSchema,
collectionSchema
collectionSchema,
annotationSchema
};