From ff0ceb2879c77f47deba5f5150414913cd2c3434 Mon Sep 17 00:00:00 2001 From: Pooja Date: Wed, 25 Jun 2025 20:26:42 +0530 Subject: [PATCH] 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 --- .../CodeView/StyledWrapper.js | 46 +- .../GenerateCodeItem/CodeView/index.js | 73 ++- .../CodeViewToolbar/StyledWrapper.js | 117 +++++ .../GenerateCodeItem/CodeViewToolbar/index.js | 106 +++++ .../GenerateCodeItem/StyledWrapper.js | 74 ++- .../CollectionItem/GenerateCodeItem/index.js | 126 ++---- .../GenerateCodeItem/utils/auth-utils.js | 49 ++ .../GenerateCodeItem/utils/auth-utils.spec.js | 68 +++ .../GenerateCodeItem/utils/interpolation.js | 88 ++++ .../utils/interpolation.spec.js | 48 ++ .../utils/snippet-generator.js | 63 +++ .../utils/snippet-generator.spec.js | 421 ++++++++++++++++++ .../src/providers/ReduxStore/slices/app.js | 14 +- 13 files changed, 1103 insertions(+), 190 deletions(-) create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js index ff06f4f31..181a258ae 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js @@ -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); } } `; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js index ea3ed43a7..307cab01a 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js @@ -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 ( - <> - - toast.success('Copied to clipboard!')} - > + + toast.success('Copied to clipboard!')} + > + + +
{ font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} theme={displayedTheme} - mode={lang} + mode={language.language} + enableVariableHighlighting={true} /> - - +
+
); }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js new file mode 100644 index 000000000..c73d2ae39 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js @@ -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; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js new file mode 100644 index 000000000..2e63ce384 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js @@ -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 ( + +
+
+
+ + +
+ + {availableLibraries.length > 1 && ( +
+ {availableLibraries.map((lib) => ( + + ))} +
+ )} +
+ +
+ +
+
+
+ ); +}; + +export default CodeViewToolbar; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js index 3d8ea1229..324e9ec3c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js @@ -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; } } `; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js index f31caf9ab..aabaafcba 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js @@ -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 ( -
-
-
- {languages && - languages.length && - languages.map((language) => ( -
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]); +
+ - // 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} - > - {language.name} -
- ))} -
-
-
+
{isValidUrl(finalUrl) ? ( { }} /> ) : ( -
-
-

Invalid URL: {finalUrl}

-

Please check the URL and try again

-
+
+

Invalid URL: {finalUrl}

+

Please check the URL and try again

)}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js new file mode 100644 index 000000000..25a392e8c --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js @@ -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 + }; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js new file mode 100644 index 000000000..407f2af87 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js @@ -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'); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js new file mode 100644 index 000000000..b9aa5ba2e --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js @@ -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 + } + } + }; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js new file mode 100644 index 000000000..8c5920b76 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js new file mode 100644 index 000000000..6be76f170 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js @@ -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 +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js new file mode 100644 index 000000000..b765f3026 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js @@ -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}}}\''); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 0fde3c8b2..900cf24b6 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -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) => {