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 index b4697ddc1..cc79bf3cb 100644 --- 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 @@ -1,38 +1,88 @@ -import { interpolate } from '@usebruno/common'; -import { isPlainObject, mapValues } from 'lodash-es'; +import { interpolate, interpolateObject } from '@usebruno/common'; +import { cloneDeep } from 'lodash'; -/** - * Traverses an object and interpolates any strings it finds. - */ -export const interpolateObject = (obj, variables) => { - const seen = new WeakSet(); - - const walk = (value) => { - if (value == null) return value; - - if (typeof value === 'string') { - return interpolate(value, variables); - } - - if (typeof value === 'object') { - if (seen.has(value)) { - throw new Error( - 'Circular reference detected during interpolation.' - ); - } - seen.add(value); - } - - if (Array.isArray(value)) { - return value.map(walk); - } - - if (isPlainObject(value)) { - return mapValues(value, walk); - } - - return value; - }; - - return walk(obj); +export const interpolateAuth = (auth, variables = {}) => { + if (!auth) return auth; + return interpolateObject(auth, variables); +}; + +export const interpolateHeaders = (headers = [], variables = {}) => { + if (!headers) return []; + return headers.map((header) => { + if (header.enabled) { + return interpolateObject(header, variables); + } + return header; + }); +}; + +export const interpolateParams = (params = [], variables = {}) => { + if (!params) return []; + return params.map((param) => { + if (param.enabled) { + return interpolateObject(param, variables); + } + return param; + }); +}; + +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 = Array.isArray(body.formUrlEncoded) + ? body.formUrlEncoded.map((param) => ({ + ...param, + value: param.enabled ? interpolate(param.value, variables) : param.value + })) + : []; + break; + + case 'multipartForm': + interpolatedBody.multipartForm = Array.isArray(body.multipartForm) + ? body.multipartForm.map((param) => ({ + ...param, + value: + param.type === 'text' && param.enabled + ? interpolate(param.value, variables) + : param.value + })) + : []; + break; + + default: + break; + } + + return interpolatedBody; }; 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 index 1810f4016..212e6b68f 100644 --- 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 @@ -1,97 +1,139 @@ -import { interpolateObject } from './interpolation'; +import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation'; describe('interpolation utils', () => { - describe('interpolateObject', () => { - it('should interpolate variables across all data types and nesting levels', () => { - const complexRequest = { - url: 'https://{{host}}/api', - method: 'POST', - headers: [{ name: 'X-{{headerName}}', value: '{{headerValue}}', enabled: true }], - auth: { - basic: { - username: '{{user}}', - password: 'pass-{{passVar}}' - } - }, - body: { - mode: 'json', - json: '{"id": "{{id}}"}' - }, - params: { - someArray: ['tag-{{id}}', 'stable'], - value: 100, - enabled: true, - isNull: null + describe('interpolateAuth', () => { + it('should interpolate auth object', () => { + const auth = { + mode: 'basic', + basic: { + username: '{{user}}', + password: '{{pass}}' } }; + const variables = { user: 'admin', pass: 'secret' }; - const variables = { - host: 'api.example.com', - headerName: 'App-ID', - headerValue: 'val-123', - user: 'admin', - passVar: 'secure', - id: '99' - }; - - const result = interpolateObject(complexRequest, variables); + const result = interpolateAuth(auth, variables); expect(result).toEqual({ - url: 'https://api.example.com/api', - method: 'POST', - headers: [{ name: 'X-App-ID', value: 'val-123', enabled: true }], - auth: { - basic: { - username: 'admin', - password: 'pass-secure' - } - }, - body: { - mode: 'json', - json: '{"id": "99"}' - }, - params: { - someArray: ['tag-99', 'stable'], - value: 100, - enabled: true, - isNull: null + mode: 'basic', + basic: { + username: 'admin', + password: 'secret' } }); }); - it('should not iterate endlessly for circular references', () => { - const variables = { x: 'ok' }; - - const obj = { value: '{{x}}' }; - obj.self = obj; - - expect(() => interpolateObject(obj, variables)).toThrow('Circular reference detected during interpolation.'); + it('should return null for null auth', () => { + expect(interpolateAuth(null, {})).toBeNull(); }); - it('should leave the placeholder intact if the variable is missing', () => { - const variables = { known: 'value' }; - const obj = { - field: '{{known}} and {{missing}}' + it('should return undefined for undefined auth', () => { + expect(interpolateAuth(undefined, {})).toBeUndefined(); + }); + }); + + describe('interpolateHeaders', () => { + it('should interpolate header names and values', () => { + const headers = [ + { name: 'X-{{headerName}}', value: '{{headerValue}}', enabled: true }, + { name: 'Content-Type', value: 'application/json', enabled: true } + ]; + const variables = { headerName: 'Custom', headerValue: 'test-value' }; + + const result = interpolateHeaders(headers, variables); + + expect(result).toEqual([ + { name: 'X-Custom', value: 'test-value', enabled: true }, + { name: 'Content-Type', value: 'application/json', enabled: true } + ]); + }); + + it('should return empty array for empty headers', () => { + expect(interpolateHeaders([], {})).toEqual([]); + }); + }); + + describe('interpolateBody', () => { + it('should return null for null body', () => { + expect(interpolateBody(null, {})).toBeNull(); + }); + + it('should interpolate JSON body with escaping', () => { + const body = { + mode: 'json', + json: '{"name": "{{name}}", "count": {{count}}}' }; + const variables = { name: 'Test', count: 42 }; - const result = interpolateObject(obj, variables); + const result = interpolateBody(body, variables); - expect(result).toEqual({ - field: 'value and {{missing}}' - }); + expect(result.mode).toBe('json'); + expect(JSON.parse(result.json)).toEqual({ name: 'Test', count: 42 }); }); it('should interpolate text body', () => { - const body = { - mode: 'text', - text: 'Hello {{name}}' - }; - const result = interpolateObject(body, { name: 'World' }); + 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(interpolateObject(null, { a: 1 })).toBeNull(); + it('should interpolate xml body', () => { + const body = { mode: 'xml', xml: '{{name}}' }; + const result = interpolateBody(body, { name: 'Alice' }); + expect(result.xml).toBe('Alice'); + }); + + it('should interpolate formUrlEncoded body for enabled params only', () => { + const body = { + mode: 'formUrlEncoded', + formUrlEncoded: [ + { name: 'key1', value: '{{val1}}', enabled: true }, + { name: 'key2', value: '{{val2}}', enabled: false } + ] + }; + const variables = { val1: 'value1', val2: 'value2' }; + + const result = interpolateBody(body, variables); + + expect(result.formUrlEncoded[0].value).toBe('value1'); + expect(result.formUrlEncoded[1].value).toBe('{{val2}}'); + }); + + it('should interpolate multipartForm body for enabled text params only', () => { + const body = { + mode: 'multipartForm', + multipartForm: [ + { name: 'field1', value: '{{val}}', type: 'text', enabled: true }, + { name: 'field2', value: '{{val}}', type: 'file', enabled: true } + ] + }; + const variables = { val: 'interpolated' }; + + const result = interpolateBody(body, variables); + + expect(result.multipartForm[0].value).toBe('interpolated'); + expect(result.multipartForm[1].value).toBe('{{val}}'); + }); + }); + + describe('interpolateParams', () => { + it('should interpolate param names and values', () => { + const params = [ + { name: '{{paramName}}', value: '{{paramValue}}', enabled: true }, + { name: 'static', value: '{{val}}', enabled: false } + ]; + const variables = { paramName: 'key', paramValue: 'value', val: 'skipped' }; + + const result = interpolateParams(params, variables); + + expect(result[0].name).toBe('key'); + expect(result[0].value).toBe('value'); + expect(result[1].name).toBe('static'); + expect(result[1].value).toBe('{{val}}'); + }); + + it('should return empty array for empty params', () => { + expect(interpolateParams([], {})).toEqual([]); }); }); }); 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 index 746d2b74f..b7a573baf 100644 --- 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 @@ -1,9 +1,7 @@ import { buildHarRequest } from 'utils/codegenerator/har'; import { getAuthHeaders } from 'utils/codegenerator/auth'; -import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections'; -import { interpolateObject } from './interpolation'; -import { get } from 'lodash'; -import interpolateVars from 'bruno/src/ipc/network/interpolate-vars'; +import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections/index'; +import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation'; const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => { try { @@ -11,24 +9,29 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false const { HTTPSnippet } = require('httpsnippet'); const variables = getAllVariables(collection, item); - - let request = item.request; - - if (shouldInterpolate) { - request = interpolateObject(request, variables); - } + const request = item.request; // Get the request tree path and merge headers const requestTreePath = getTreePathFromCollectionToItem(collection, item); let headers = mergeHeaders(collection, request, requestTreePath); - // Add auth headers if needed + // Add auth headers if needed (auth inheritance is resolved upstream) if (request.auth && request.auth.mode !== 'none') { - const collectionAuth = collection?.draft?.root ? get(collection, 'draft.root.request.auth', null) : get(collection, 'root.request.auth', null); - const authHeaders = getAuthHeaders(collectionAuth, request.auth, collection, item); + if (shouldInterpolate) { + request.auth = interpolateAuth(request.auth, variables); + } + + const authHeaders = getAuthHeaders(request.auth, collection, item); headers = [...headers, ...authHeaders]; } + // Interpolate headers, body and params if needed + if (shouldInterpolate) { + headers = interpolateHeaders(headers, variables); + request.body = interpolateBody(request.body, variables); + request.params = interpolateParams(request.params, variables); + } + // Build HAR request const harRequest = buildHarRequest({ request, @@ -37,8 +40,9 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false // Generate snippet using HTTPSnippet const snippet = new HTTPSnippet(harRequest); + const result = snippet.convert(language.target, language.client); - return snippet.convert(language.target, language.client); + return result; } catch (error) { console.error('Error generating code snippet:', error); return 'Error generating code snippet'; 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 index 1133b5a77..3c7e2c5ef 100644 --- 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 @@ -150,8 +150,11 @@ describe('Snippet Generator - Simple Tests', () => { shouldInterpolate: true }); - const expectedBody = `{"message": "Hello World", "count": 42}`; - expect(result).toBe(`curl -X POST https://api.example.com/data -H "Content-Type: application/json" -d '${expectedBody}'`); + 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', () => { @@ -204,8 +207,11 @@ describe('Snippet Generator - Simple Tests', () => { }); // 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/data -H "Content-Type: application/json" -d '${expectedBody}'`); + 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', () => { @@ -267,7 +273,7 @@ describe('Snippet Generator - Simple Tests', () => { } }, null, 2); - expect(result).toBe(`curl -X POST https://api.example.com/data -H "Content-Type: application/json" -d '${expectedComplexBody}'`); + expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedComplexBody}'`); }); it('should handle errors gracefully', () => { @@ -367,9 +373,13 @@ describe('Snippet Generator - Simple Tests', () => { shouldInterpolate: true }); - const expectedInterpolatedBody = `{"name": "John Smith", "email": "john@test.com", "age": 30}`; + const expectedInterpolatedBody = `{ + "name": "John Smith", + "email": "john@test.com", + "age": 30 +}`; - expect(result).toBe(`curl -X POST https://api.test.com/users -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`); + 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', () => { @@ -418,12 +428,12 @@ describe('Snippet Generator - Simple Tests', () => { 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}}}\'' - ); + expect(result).toBe('curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}\''); }); - it('should interpolate basic auth credentials correctly', () => { + it('should interpolate auth credentials correctly', () => { + // Auth inheritance is resolved upstream in index.js before calling generateSnippet + // So the item already has the resolved auth (not 'inherit' mode) const item = { request: { method: 'GET', @@ -441,12 +451,7 @@ describe('Snippet Generator - Simple Tests', () => { const collection = { root: { request: { - vars: { - req: [ - { name: 'user', value: 'admin', enabled: true }, - { name: 'pass', value: 'secret123', enabled: true } - ] - } + auth: { mode: 'none' } } } }; @@ -611,7 +616,7 @@ describe('generateSnippet with OAuth2 authentication', () => { jest.clearAllMocks(); // Mock getAuthHeaders to return OAuth2 headers based on the auth config const authUtils = require('utils/codegenerator/auth'); - authUtils.getAuthHeaders.mockImplementation((collectionRootAuth, requestAuth, collection = null, item = null) => { + authUtils.getAuthHeaders.mockImplementation((requestAuth, collection = null, item = null) => { if (requestAuth?.mode === 'oauth2') { const oauth2Config = requestAuth.oauth2 || {}; const tokenPlacement = oauth2Config.tokenPlacement || 'header'; diff --git a/packages/bruno-app/src/utils/codegenerator/auth.js b/packages/bruno-app/src/utils/codegenerator/auth.js index eefb82cfe..51da6d3c9 100644 --- a/packages/bruno-app/src/utils/codegenerator/auth.js +++ b/packages/bruno-app/src/utils/codegenerator/auth.js @@ -3,21 +3,16 @@ import { find } from 'lodash'; import { interpolate } from '@usebruno/common'; import { getAllVariables } from 'utils/collections/index'; -export const getAuthHeaders = (collectionRootAuth, requestAuth, collection = null, item = null) => { - // Discovered edge case where code generation fails when you create a collection which has not been saved yet: - // Collection auth therefore null, and request inherits from collection, therefore it is also null - // TypeError: Cannot read properties of undefined (reading 'mode') - // at getAuthHeaders - if (!collectionRootAuth && !requestAuth) { +export const getAuthHeaders = (requestAuth, collection = null, item = null) => { + // Auth inheritance is resolved upstream, so requestAuth should never have mode 'inherit' + if (!requestAuth) { return []; } - const auth = collectionRootAuth && ['inherit'].includes(requestAuth?.mode) ? collectionRootAuth : requestAuth; - - switch (auth.mode) { + switch (requestAuth.mode) { case 'basic': - const username = get(auth, 'basic.username', ''); - const password = get(auth, 'basic.password', ''); + const username = get(requestAuth, 'basic.username', ''); + const password = get(requestAuth, 'basic.password', ''); const basicToken = Buffer.from(`${username}:${password}`).toString('base64'); return [ @@ -32,11 +27,11 @@ export const getAuthHeaders = (collectionRootAuth, requestAuth, collection = nul { enabled: true, name: 'Authorization', - value: `Bearer ${get(auth, 'bearer.token', '')}` + value: `Bearer ${get(requestAuth, 'bearer.token', '')}` } ]; case 'apikey': - const apiKeyAuth = get(auth, 'apikey', {}); + const apiKeyAuth = get(requestAuth, 'apikey', {}); const key = get(apiKeyAuth, 'key', ''); const value = get(apiKeyAuth, 'value', ''); const placement = get(apiKeyAuth, 'placement', 'header'); @@ -52,7 +47,7 @@ export const getAuthHeaders = (collectionRootAuth, requestAuth, collection = nul } return []; case 'oauth2': { - const oauth2Config = get(auth, 'oauth2', {}); + const oauth2Config = get(requestAuth, 'oauth2', {}); const tokenPlacement = get(oauth2Config, 'tokenPlacement', 'header'); const tokenHeaderPrefix = get(oauth2Config, 'tokenHeaderPrefix', 'Bearer');