feat: add dropdown to select language and add lib selector in code gen (#4345)

* feat: add dropdown to select language and add lib selector in code gen

* add: checkbox for interpolation

* rm: url should interpolate from url

* add: search in dropdown

* fixes

* add: autofocus for search

* add: arrow navigation in select

* fix

code improvements

fix

rm: editor wrapper

rm: font-size

improvement

rm: custom select

rm comments and add sparql mode

rm: styles

* add: tests and fixes

* fixes: file naming

* rm: comments

* fix

* fix: unit tests

* improvements

* fixes

* fix: indentation

* fix

* fixes: CodeViewToolbar

* trim: extra spaces
This commit is contained in:
Pooja
2025-06-25 20:26:42 +05:30
committed by GitHub
parent 4d7c044eba
commit ff0ceb2879
13 changed files with 1103 additions and 190 deletions

View File

@@ -1,19 +1,59 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
height: 100%;
position: relative;
.editor-content {
height: 100%;
.CodeMirror {
height: 100%;
font-size: 12px;
line-height: 1.5;
padding: 0;
.CodeMirror-gutters {
background: ${props => props.theme.codemirror.gutter.bg};
border-right: 1px solid ${props => props.theme.codemirror.border};
}
.CodeMirror-linenumber {
color: ${props => props.theme.colors.text.muted};
font-size: 11px;
padding: 0 3px 0 5px;
}
.CodeMirror-lines {
padding: 0;
}
.CodeMirror-line {
padding: 0 4px;
}
}
}
.copy-to-clipboard {
position: absolute;
cursor: pointer;
top: 10px;
right: 10px;
z-index: 10;
opacity: 0.5;
background: transparent;
border: none;
color: ${props => props.theme.colors.text.muted};
cursor: pointer;
padding: 6px;
opacity: 0.7;
transition: all 0.2s ease;
&:hover {
opacity: 1;
color: ${props => props.theme.text};
}
&:active {
transform: translateY(1px);
}
}
`;

View File

@@ -1,64 +1,52 @@
import CodeEditor from 'components/CodeEditor/index';
import get from 'lodash/get';
import { HTTPSnippet } from 'httpsnippet';
import { useTheme } from 'providers/Theme/index';
import StyledWrapper from './StyledWrapper';
import { buildHarRequest } from 'utils/codegenerator/har';
import { useSelector } from 'react-redux';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import toast from 'react-hot-toast';
import { IconCopy } from '@tabler/icons';
import { findCollectionByItemUid, getGlobalEnvironmentVariables } from '../../../../../../../utils/collections/index';
import { getAuthHeaders } from '../../../../../../../utils/codegenerator/auth';
import { findCollectionByItemUid, getGlobalEnvironmentVariables } from 'utils/collections/index';
import { cloneDeep } from 'lodash';
import { useMemo } from 'react';
import { generateSnippet } from '../utils/snippet-generator';
const CodeView = ({ language, item }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const { target, client, language: lang } = language;
const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
let _collection = findCollectionByItemUid(
const generateCodePrefs = useSelector((state) => state.app.generateCode);
let collectionOriginal = findCollectionByItemUid(
useSelector((state) => state.collections.collections),
item.uid
);
let collection = cloneDeep(_collection);
const collection = useMemo(() => {
const c = cloneDeep(collectionOriginal);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({
globalEnvironments,
activeGlobalEnvironmentUid
});
c.globalEnvironmentVariables = globalEnvironmentVariables;
return c;
}, [collectionOriginal, globalEnvironments, activeGlobalEnvironmentUid]);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collection.globalEnvironmentVariables = globalEnvironmentVariables;
const collectionRootAuth = collection?.root?.request?.auth;
const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth');
const headers = [
...getAuthHeaders(collectionRootAuth, requestAuth),
...(collection?.root?.request?.headers || []),
...(requestHeaders || [])
];
let snippet = '';
try {
snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers, type: item.type })).convert(
target,
client
);
} catch (e) {
console.error(e);
snippet = 'Error generating code snippet';
}
const snippet = useMemo(() => {
return generateSnippet({ language, item, collection, shouldInterpolate: generateCodePrefs.shouldInterpolate });
}, [language, item, collection, generateCodePrefs.shouldInterpolate]);
return (
<>
<StyledWrapper>
<CopyToClipboard
className="copy-to-clipboard"
text={snippet}
onCopy={() => toast.success('Copied to clipboard!')}
>
<StyledWrapper>
<CopyToClipboard
text={snippet}
onCopy={() => toast.success('Copied to clipboard!')}
>
<button className="copy-to-clipboard">
<IconCopy size={25} strokeWidth={1.5} />
</CopyToClipboard>
</button>
</CopyToClipboard>
<div className="editor-content">
<CodeEditor
readOnly
collection={collection}
@@ -67,10 +55,11 @@ const CodeView = ({ language, item }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
mode={lang}
mode={language.language}
enableVariableHighlighting={true}
/>
</StyledWrapper>
</>
</div>
</StyledWrapper>
);
};

View File

@@ -0,0 +1,117 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: ${props => props.theme.requestTabPanel.card.bg};
border-bottom: 1px solid ${props => props.theme.requestTabPanel.card.border};
gap: 12px;
flex-shrink: 0;
}
.left-controls {
display: flex;
align-items: center;
gap: 12px;
}
.select-wrapper {
position: relative;
display: flex;
align-items: center;
}
.select-arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: ${props => props.theme.colors.text.muted};
}
.native-select {
background: ${props => props.theme.requestTabPanel.url.bg};
border: 1px solid ${props => props.theme.input.border};
border-radius: 3px;
color: ${props => props.theme.text};
font-size: 12px;
padding: 6px 28px 6px 10px;
min-width: 140px;
height: 32px;
cursor: pointer;
transition: all 0.2s ease;
appearance: none;
&:hover {
border-color: ${props => props.theme.input.focusBorder};
}
&:focus {
outline: none;
border-color: ${props => props.theme.input.focusBorder};
box-shadow: 0 0 0 2px ${props => props.theme.input.focusBoxShadow};
}
option {
background: ${props => props.theme.bg};
color: ${props => props.theme.text};
padding: 8px 12px;
}
}
.library-options {
display: flex;
gap: 6px;
}
.lib-btn {
height: 32px;
padding: 0 12px;
background: ${props => props.theme.requestTabPanel.url.bg};
border: 1px solid ${props => props.theme.input.border};
border-radius: 3px;
color: ${props => props.theme.text};
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
&:hover {
background: ${props => props.theme.dropdown.hoverBg};
border-color: ${props => props.theme.input.focusBorder};
}
&.active {
background: ${props => props.theme.button.secondary.bg};
border-color: ${props => props.theme.button.secondary.border};
color: ${props => props.theme.button.secondary.color};
}
}
.right-controls {
.interpolate-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: ${props => props.theme.text};
input[type="checkbox"] {
cursor: pointer;
margin: 0;
}
&:hover {
opacity: 0.8;
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,106 @@
import { IconChevronDown } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { useMemo } from 'react';
import { getLanguages } from 'utils/codegenerator/targets';
import { updateGenerateCode } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
const CodeViewToolbar = () => {
const dispatch = useDispatch();
const languages = getLanguages();
const generateCodePrefs = useSelector((state) => state.app.generateCode);
// Group languages by their main language type
const languageGroups = useMemo(() => {
return languages.reduce((acc, lang) => {
const mainLang = lang.name.split('-')[0];
if (!acc[mainLang]) {
acc[mainLang] = [];
}
acc[mainLang].push({
...lang,
libraryName: lang.name.split('-')[1] || 'default'
});
return acc;
}, {});
}, [languages]);
const mainLanguages = useMemo(() => Object.keys(languageGroups), [languageGroups]);
const availableLibraries = useMemo(() => {
return languageGroups[generateCodePrefs.mainLanguage] || [];
}, [generateCodePrefs.mainLanguage, languageGroups]);
// Event handlers
const handleMainLanguageChange = (e) => {
const newMainLang = e.target.value;
const defaultLibrary = languageGroups[newMainLang][0].libraryName;
dispatch(updateGenerateCode({
mainLanguage: newMainLang,
library: defaultLibrary
}));
};
const handleLibraryChange = (libraryName) => {
dispatch(updateGenerateCode({
library: libraryName
}));
};
const handleInterpolateChange = (e) => {
dispatch(updateGenerateCode({
shouldInterpolate: e.target.checked
}));
};
return (
<StyledWrapper>
<div className="toolbar">
<div className="left-controls">
<div className="select-wrapper">
<select
className="native-select"
value={generateCodePrefs.mainLanguage}
onChange={handleMainLanguageChange}
>
{mainLanguages.map((lang) => (
<option key={lang} value={lang}>
{lang}
</option>
))}
</select>
<IconChevronDown size={16} className="select-arrow" />
</div>
{availableLibraries.length > 1 && (
<div className="library-options">
{availableLibraries.map((lib) => (
<button
key={lib.libraryName}
className={`lib-btn ${generateCodePrefs.library === lib.libraryName ? 'active' : ''}`}
onClick={() => handleLibraryChange(lib.libraryName)}
>
{lib.libraryName}
</button>
))}
</div>
)}
</div>
<div className="right-controls">
<label className="interpolate-checkbox">
<input
type="checkbox"
checked={generateCodePrefs.shouldInterpolate}
onChange={handleInterpolateChange}
/>
<span>Interpolate Variables</span>
</label>
</div>
</div>
</StyledWrapper>
);
};
export default CodeViewToolbar;

View File

@@ -1,60 +1,44 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-inline: -1rem;
margin-block: -1.5rem;
margin: -1.5rem -1rem;
height: 50vh;
display: flex;
flex-direction: column;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
.generate-code-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
max-height: 80vh;
.code-generator {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.generate-code-item {
min-width: 150px;
display: block;
.editor-container {
flex: 1;
overflow: hidden;
position: relative;
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
background: ${props => props.theme.bg};
}
&:hover {
text-decoration: none;
background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
.error-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: ${props => props.theme.colors.text.muted};
text-align: center;
padding: 20px;
h1 {
font-size: 14px;
margin-bottom: 8px;
color: ${props => props.theme.text};
}
}
.active {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
&:hover {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
.flexible-container {
width: 100%;
}
@media (max-width: 600px) {
.flexible-container {
width: 500px;
}
}
@media (min-width: 601px) and (max-width: 1200px) {
.flexible-container {
width: 800px;
}
}
@media (min-width: 1201px) {
.flexible-container {
width: 900px;
p {
font-size: 12px;
opacity: 0.8;
}
}
`;

View File

@@ -1,72 +1,30 @@
import Modal from 'components/Modal/index';
import { useState } from 'react';
import { useMemo } from 'react';
import CodeView from './CodeView';
import CodeViewToolbar from './CodeViewToolbar';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url';
import { get } from 'lodash';
import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection } from 'utils/collections';
import {
findEnvironmentInCollection
} from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const getTreePathFromCollectionToItem = (collection, _itemUid) => {
let path = [];
let item = findItemInCollection(collection, _itemUid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
// Function to resolve inherited auth
const resolveInheritedAuth = (item, collection) => {
const request = item.draft?.request || item.request;
const authMode = request?.auth?.mode;
// If auth is not inherit or no auth defined, return the request as is
if (!authMode || authMode !== 'inherit') {
return {
...request
};
}
// Get the tree path from collection to item
const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid);
// Default to collection auth
const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
let effectiveAuth = collectionAuth;
let source = 'collection';
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
source = 'folder';
break;
}
}
}
return {
...request,
auth: effectiveAuth
};
};
import { resolveInheritedAuth } from './utils/auth-utils';
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const languages = getLanguages();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
const generateCodePrefs = useSelector((state) => state.app.generateCode);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({
globalEnvironments,
activeGlobalEnvironmentUid
});
const environment = findEnvironmentInCollection(collection, collection?.activeEnvironmentUid);
let envVars = {};
if (environment) {
const vars = get(environment, 'variables', []);
@@ -79,7 +37,6 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const requestUrl =
get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
// interpolate the url
const interpolatedUrl = interpolateUrl({
url: requestUrl,
globalEnvironmentVariables,
@@ -94,54 +51,27 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
);
// Get the full language object based on current preferences
const selectedLanguage = useMemo(() => {
const fullName = generateCodePrefs.library === 'default'
? generateCodePrefs.mainLanguage
: `${generateCodePrefs.mainLanguage}-${generateCodePrefs.library}`;
return languages.find(lang => lang.name === fullName) || languages[0];
}, [generateCodePrefs.mainLanguage, generateCodePrefs.library, languages]);
// Resolve auth inheritance
const resolvedRequest = resolveInheritedAuth(item, collection);
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
<StyledWrapper>
<div className="flex w-full flexible-container">
<div>
<div className="generate-code-sidebar">
{languages &&
languages.length &&
languages.map((language) => (
<div
key={language.name}
className={
language.name === selectedLanguage.name ? 'generate-code-item active' : 'generate-code-item'
}
role="button"
tabIndex={0}
onClick={() => setSelectedLanguage(language)}
onKeyDown={(e) => {
if (e.key === 'Tab' || (e.shiftKey && e.key === 'Tab')) {
e.preventDefault();
const currentIndex = languages.findIndex((lang) => lang.name === selectedLanguage.name);
const nextIndex = e.shiftKey
? (currentIndex - 1 + languages.length) % languages.length
: (currentIndex + 1) % languages.length;
setSelectedLanguage(languages[nextIndex]);
<div className="code-generator">
<CodeViewToolbar />
// Explicitly focus on the new active element
const nextElement = document.querySelector(`[data-language="${languages[nextIndex].name}"]`);
nextElement?.focus();
}
}}
data-language={language.name}
aria-pressed={language.name === selectedLanguage.name}
>
<span className="capitalize">{language.name}</span>
</div>
))}
</div>
</div>
<div className="flex-grow p-4">
<div className="editor-container">
{isValidUrl(finalUrl) ? (
<CodeView
tabIndex={-1}
language={selectedLanguage}
item={{
...item,
@@ -152,11 +82,9 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
}}
/>
) : (
<div className="flex flex-col justify-center items-center w-full">
<div className="text-center">
<h1 className="text-2xl font-bold">Invalid URL: {finalUrl}</h1>
<p className="text-gray-500">Please check the URL and try again</p>
</div>
<div className="error-message">
<h1>Invalid URL: {finalUrl}</h1>
<p>Please check the URL and try again</p>
</div>
)}
</div>

View File

@@ -0,0 +1,49 @@
import { get } from 'lodash';
import {
findItemInCollection,
findParentItemInCollection
} from 'utils/collections';
export const getTreePathFromCollectionToItem = (collection, _itemUid) => {
let path = [];
let item = findItemInCollection(collection, _itemUid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
// Resolve inherited auth by traversing up the folder hierarchy
export const resolveInheritedAuth = (item, collection) => {
const request = item.draft?.request || item.request;
const authMode = request?.auth?.mode;
// If auth is not inherit or no auth defined, return the request as is
if (!authMode || authMode !== 'inherit') {
return request;
}
// Get the tree path from collection to item
const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid);
// Default to collection auth
const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
let effectiveAuth = collectionAuth;
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
break;
}
}
}
return {
...request,
auth: effectiveAuth
};
};

View File

@@ -0,0 +1,68 @@
import { resolveInheritedAuth } from './auth-utils';
// Helper to build mock collection structure
const buildCollection = () => {
return {
uid: 'c1',
root: {
request: {
auth: { mode: 'bearer', bearer: { token: 'COLLECTION' } }
}
},
items: [
{
uid: 'f1',
type: 'folder',
name: 'Folder',
root: {
request: {
auth: { mode: 'basic', basic: { username: 'user', password: 'pass' } }
}
},
items: [
{
uid: 'r1',
type: 'request',
name: 'Request',
request: {
auth: { mode: 'inherit' },
url: 'http://example.com',
method: 'GET'
}
}
]
}
]
};
};
describe('auth-utils.resolveInheritedAuth', () => {
it('should resolve to nearest folder auth when request mode is inherit', () => {
const collection = buildCollection();
const item = collection.items[0].items[0]; // r1
const resolved = resolveInheritedAuth(item, collection);
expect(resolved.auth.mode).toBe('basic');
expect(resolved.auth.basic.username).toBe('user');
});
it('should resolve to collection auth if no folder auth', () => {
const collection = buildCollection();
collection.items[0].root.request.auth = { mode: 'inherit' };
const item = collection.items[0].items[0];
const resolved = resolveInheritedAuth(item, collection);
expect(resolved.auth.mode).toBe('bearer');
expect(resolved.auth.bearer.token).toBe('COLLECTION');
});
it('should return original request when mode is not inherit', () => {
const collection = buildCollection();
const item = collection.items[0].items[0];
item.request.auth = { mode: 'basic', basic: { username: 'override', password: 'pwd' } };
const resolved = resolveInheritedAuth(item, collection);
expect(resolved.auth.mode).toBe('basic');
expect(resolved.auth.basic.username).toBe('override');
});
});

View File

@@ -0,0 +1,88 @@
import { interpolate } from '@usebruno/common';
import { cloneDeep } from 'lodash';
export const interpolateHeaders = (headers = [], variables = {}) => {
return headers.map((header) => ({
...header,
name: interpolate(header.name, variables),
value: interpolate(header.value, variables)
}));
};
export const interpolateBody = (body, variables = {}) => {
if (!body) return null;
const interpolatedBody = cloneDeep(body);
switch (body.mode) {
case 'json':
let parsed = body.json;
// If it's already a string, use it directly; if it's an object, stringify it first
if (typeof parsed === 'object') {
parsed = JSON.stringify(parsed);
}
parsed = interpolate(parsed, variables, { escapeJSONStrings: true });
try {
const jsonObj = JSON.parse(parsed);
interpolatedBody.json = JSON.stringify(jsonObj, null, 2);
} catch {
interpolatedBody.json = parsed;
}
break;
case 'text':
interpolatedBody.text = interpolate(body.text, variables);
break;
case 'xml':
interpolatedBody.xml = interpolate(body.xml, variables);
break;
case 'sparql':
interpolatedBody.sparql = interpolate(body.sparql, variables);
break;
case 'formUrlEncoded':
interpolatedBody.formUrlEncoded = body.formUrlEncoded.map((param) => ({
...param,
value: param.enabled ? interpolate(param.value, variables) : param.value
}));
break;
case 'multipartForm':
interpolatedBody.multipartForm = body.multipartForm.map((param) => ({
...param,
value:
param.type === 'text' && param.enabled
? interpolate(param.value, variables)
: param.value
}));
break;
default:
break;
}
return interpolatedBody;
};
export const createVariablesObject = ({
globalEnvironmentVariables = {},
collectionVars = {},
allVariables = {},
collection = {},
runtimeVariables = {},
processEnvVars = {}
}) => {
return {
...globalEnvironmentVariables,
...allVariables,
...collectionVars,
...runtimeVariables,
process: {
env: {
...processEnvVars
}
}
};
};

View File

@@ -0,0 +1,48 @@
import { interpolateHeaders, interpolateBody } from './interpolation';
describe('interpolation utils', () => {
describe('interpolateHeaders', () => {
it('should interpolate variables in header name and value while preserving other props', () => {
const headers = [
{ uid: '1', name: 'X-{{var}}', value: 'value-{{var}}', enabled: true }
];
const variables = { var: 'test' };
const result = interpolateHeaders(headers, variables);
expect(result).toEqual([
{
uid: '1',
name: 'X-test',
value: 'value-test',
enabled: true
}
]);
});
});
describe('interpolateBody', () => {
it('should interpolate JSON body strings and keep formatting', () => {
const body = {
mode: 'json',
json: '{"name": "{{username}}"}'
};
const variables = { username: 'bruno' };
const result = interpolateBody(body, variables);
expect(result.json).toBe('{\n "name": "bruno"\n}');
});
it('should interpolate text body', () => {
const body = {
mode: 'text',
text: 'Hello {{name}}'
};
const result = interpolateBody(body, { name: 'World' });
expect(result.text).toBe('Hello World');
});
it('should return null when body is null', () => {
expect(interpolateBody(null, { a: 1 })).toBeNull();
});
});
});

View File

@@ -0,0 +1,63 @@
import { buildHarRequest } from 'utils/codegenerator/har';
import { getAuthHeaders } from 'utils/codegenerator/auth';
import { getAllVariables } from 'utils/collections/index';
import { interpolateHeaders, interpolateBody, createVariablesObject } from './interpolation';
import { resolveInheritedAuth } from './auth-utils';
const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => {
try {
// Get HTTPSnippet dynamically so mocks can be applied in tests
const { HTTPSnippet } = require('httpsnippet');
const allVariables = getAllVariables(collection, item);
// Create variables object for interpolation
const variables = createVariablesObject({
globalEnvironmentVariables: collection.globalEnvironmentVariables || {},
collectionVars: collection.collectionVars || {},
allVariables,
collection,
runtimeVariables: collection.runtimeVariables || {},
processEnvVars: collection.processEnvVariables || {}
});
// Get the request with resolved auth
const request = resolveInheritedAuth(item, collection);
// Prepare headers
let headers = [...(request.headers || [])];
// Add auth headers if needed
if (request.auth && request.auth.mode !== 'none') {
const authHeaders = getAuthHeaders(request.auth, variables);
headers = [...headers, ...authHeaders];
}
// Interpolate headers and body if needed
if (shouldInterpolate) {
headers = interpolateHeaders(headers, variables);
if (request.body) {
request.body = interpolateBody(request.body, variables);
}
}
// Build HAR request
const harRequest = buildHarRequest({
request,
headers
});
// Generate snippet using HTTPSnippet
const snippet = new HTTPSnippet(harRequest);
const result = snippet.convert(language.target, language.client);
return result;
} catch (error) {
console.error('Error generating code snippet:', error);
return 'Error generating code snippet';
}
};
export {
generateSnippet
};

View File

@@ -0,0 +1,421 @@
jest.mock('httpsnippet', () => {
return {
HTTPSnippet: jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn(() => {
const method = harRequest?.method || 'GET';
const url = harRequest?.url || 'http://example.com';
const hasBody = harRequest?.postData?.text;
if (method === 'POST' && hasBody) {
return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`;
}
return `curl -X ${method} ${url}`;
})
}))
};
});
jest.mock('utils/codegenerator/har', () => ({
buildHarRequest: jest.fn((data) => {
const request = data.request || {};
const method = request.method || 'GET';
const url = request.url || 'http://example.com';
const body = request.body || {};
const harRequest = {
method: method,
url: url,
headers: data.headers || [],
httpVersion: 'HTTP/1.1'
};
// Add body data for POST requests
if (method === 'POST' && body.mode === 'json' && body.json) {
harRequest.postData = {
mimeType: 'application/json',
text: body.json
};
}
return harRequest;
})
}));
jest.mock('utils/codegenerator/auth', () => ({
getAuthHeaders: jest.fn(() => [])
}));
jest.mock('utils/collections/index', () => ({
getAllVariables: jest.fn(() => ({
baseUrl: 'https://api.example.com',
apiKey: 'secret-key-123',
userId: '12345'
}))
}));
import { generateSnippet } from './snippet-generator';
describe('Snippet Generator - Simple Tests', () => {
// Simple test request - easy to understand
const testRequest = {
uid: 'test-request-123',
name: 'test api call',
type: 'http-request',
request: {
method: 'POST',
url: 'https://api.example.com/{{endpoint}}',
headers: [
{ uid: 'h1', name: 'Authorization', value: 'Bearer {{apiToken}}', enabled: true },
{ uid: 'h2', name: 'Content-Type', value: 'application/json', enabled: true },
{ uid: 'h3', name: 'X-Custom', value: '{{customValue}}', enabled: true }
],
body: {
mode: 'json',
json: '{"message": "{{greeting}}", "count": {{number}}}'
},
auth: { mode: 'none' },
assertions: [],
tests: '',
docs: '',
params: [],
vars: { req: [] }
}
};
const testCollection = {
root: {
request: {
auth: { mode: 'none' },
headers: []
}
},
globalEnvironmentVariables: {
endpoint: 'data',
apiToken: 'token123',
customValue: 'test-value',
greeting: 'Hello World',
number: 42
},
runtimeVariables: {},
processEnvVariables: {}
};
const curlLanguage = { target: 'shell', client: 'curl' };
beforeEach(() => {
jest.clearAllMocks();
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn(() => {
const method = harRequest?.method || 'GET';
const url = harRequest?.url || 'http://example.com';
const hasBody = harRequest?.postData?.text;
if (method === 'POST' && hasBody) {
return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`;
}
return `curl -X ${method} ${url}`;
})
}));
});
it('should generate curl for POST request with JSON body', () => {
const result = generateSnippet({
language: curlLanguage,
item: testRequest,
collection: testCollection,
shouldInterpolate: false
});
expect(result).toBe('curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"message": "{{greeting}}", "count": {{number}}}\'');
});
it('should interpolate variables when enabled', () => {
const result = generateSnippet({
language: curlLanguage,
item: testRequest,
collection: testCollection,
shouldInterpolate: true
});
const expectedBody = `{
"message": "Hello World",
"count": 42
}`;
expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`);
});
it('should handle GET requests', () => {
const getRequest = {
...testRequest,
request: {
...testRequest.request,
method: 'GET',
body: { mode: 'none' }
}
};
const result = generateSnippet({
language: curlLanguage,
item: getRequest,
collection: testCollection,
shouldInterpolate: false
});
expect(result).toBe('curl -X GET https://api.example.com/{{endpoint}}');
});
it('should handle requests with different headers', () => {
const requestWithDifferentHeaders = {
...testRequest,
request: {
...testRequest.request,
headers: [
{ uid: 'h1', name: 'X-API-Key', value: '{{apiKey}}', enabled: true },
{ uid: 'h2', name: 'Accept', value: 'application/json', enabled: true },
{ uid: 'h3', name: 'User-Agent', value: 'TestApp/{{version}}', enabled: true }
]
}
};
const collectionWithDifferentVars = {
...testCollection,
globalEnvironmentVariables: {
...testCollection.globalEnvironmentVariables,
apiKey: 'secret-key-456',
version: '1.0.0'
}
};
const result = generateSnippet({
language: curlLanguage,
item: requestWithDifferentHeaders,
collection: collectionWithDifferentVars,
shouldInterpolate: true
});
// Body should have interpolated variables with proper formatting
const expectedBody = `{
"message": "Hello World",
"count": 42
}`;
expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`);
});
it('should handle complex nested JSON body', () => {
const complexBody = {
user: {
name: '{{userName}}',
settings: {
theme: '{{userTheme}}',
active: true
}
},
data: {
items: ['{{item1}}', '{{item2}}'],
total: '{{totalCount}}'
}
};
const requestWithComplexBody = {
...testRequest,
request: {
...testRequest.request,
body: {
mode: 'json',
json: JSON.stringify(complexBody, null, 2)
}
}
};
const collectionWithComplexVars = {
...testCollection,
globalEnvironmentVariables: {
...testCollection.globalEnvironmentVariables,
userName: 'Alice',
userTheme: 'dark',
item1: 'first',
item2: 'second',
totalCount: 100
}
};
const result = generateSnippet({
language: curlLanguage,
item: requestWithComplexBody,
collection: collectionWithComplexVars,
shouldInterpolate: true
});
const expectedComplexBody = JSON.stringify({
user: {
name: 'Alice',
settings: {
theme: 'dark',
active: true
}
},
data: {
items: ['first', 'second'],
total: '100'
}
}, null, 2);
expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedComplexBody}'`);
});
it('should handle errors gracefully', () => {
// Set up the error mock after beforeEach has run
const originalHTTPSnippet = require('httpsnippet').HTTPSnippet;
require('httpsnippet').HTTPSnippet = jest.fn(() => {
throw new Error('Mock error!');
});
const originalConsoleError = console.error;
console.error = jest.fn();
const result = generateSnippet({
language: curlLanguage,
item: testRequest,
collection: testCollection,
shouldInterpolate: false
});
expect(result).toBe('Error generating code snippet');
require('httpsnippet').HTTPSnippet = originalHTTPSnippet;
console.error = originalConsoleError;
});
it('should work with JavaScript language', () => {
const javascriptLanguage = { target: 'javascript', client: 'fetch' };
const expectedJavaScriptCode = `fetch("https://api.example.com/data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ "message": "Hello World", "count": 42 })
})`;
const originalHTTPSnippet = require('httpsnippet').HTTPSnippet;
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation(() => ({
convert: jest.fn(() => expectedJavaScriptCode)
}));
const result = generateSnippet({
language: javascriptLanguage,
item: testRequest,
collection: testCollection,
shouldInterpolate: false
});
expect(result).toBe(expectedJavaScriptCode);
// Restore the original mock
require('httpsnippet').HTTPSnippet = originalHTTPSnippet;
});
it('should interpolate simple headers and body variables', () => {
const simpleTestRequest = {
uid: 'test-123',
name: 'simple test',
type: 'http-request',
request: {
method: 'POST',
url: 'https://api.test.com/{{endpoint}}',
headers: [
{ uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true },
{ uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true },
{ uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true }
],
body: {
mode: 'json',
json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}'
}
}
};
// Simple collection with clear variable values
const simpleTestCollection = {
root: {
request: {
auth: { mode: 'none' },
headers: []
}
},
globalEnvironmentVariables: {
endpoint: 'users',
token: 'abc123token',
userId: 'user456',
userName: 'John Smith',
userEmail: 'john@test.com',
userAge: 30
},
runtimeVariables: {},
processEnvVariables: {}
};
const result = generateSnippet({
language: curlLanguage,
item: simpleTestRequest,
collection: simpleTestCollection,
shouldInterpolate: true
});
const expectedInterpolatedBody = `{
"name": "John Smith",
"email": "john@test.com",
"age": 30
}`;
expect(result).toBe(`curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`);
});
it('should NOT interpolate when shouldInterpolate is false', () => {
const simpleTestRequest = {
uid: 'test-123',
name: 'simple test',
type: 'http-request',
request: {
method: 'POST',
url: 'https://api.test.com/{{endpoint}}',
headers: [
{ uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true },
{ uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true },
{ uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true }
],
body: {
mode: 'json',
json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}'
}
}
};
const simpleTestCollection = {
root: {
request: {
auth: { mode: 'none' },
headers: []
}
},
globalEnvironmentVariables: {
endpoint: 'users',
token: 'abc123token',
userId: 'user456',
userName: 'John Smith',
userEmail: 'john@test.com',
userAge: 30
},
runtimeVariables: {},
processEnvVariables: {}
};
const result = generateSnippet({
language: curlLanguage,
item: simpleTestRequest,
collection: simpleTestCollection,
shouldInterpolate: false
});
expect(result).toBe('curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}\'');
});
});

View File

@@ -25,6 +25,11 @@ const initialState = {
codeFont: 'default'
}
},
generateCode: {
mainLanguage: 'Shell',
library: 'curl',
shouldInterpolate: true
},
cookies: [],
taskQueue: [],
systemProxyEnvVariables: {}
@@ -75,6 +80,12 @@ export const appSlice = createSlice({
},
updateSystemProxyEnvVariables: (state, action) => {
state.systemProxyEnvVariables = action.payload;
},
updateGenerateCode: (state, action) => {
state.generateCode = {
...state.generateCode,
...action.payload
};
}
}
});
@@ -93,7 +104,8 @@ export const {
insertTaskIntoQueue,
removeTaskFromQueue,
removeAllTasksFromQueue,
updateSystemProxyEnvVariables
updateSystemProxyEnvVariables,
updateGenerateCode
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {