fix: Code Generation for Basic Auth (#6474)

* Adding interpolation utilities

* Refactor interpolation

* Refactor interpolation

* updating tests

* updating tests

* minor refinements to interpolation logic

* update snippet generator to handle basic auth credentials

* move interpolation upstream
This commit is contained in:
Cmarvin1
2026-01-21 08:07:49 -05:00
committed by GitHub
parent 75e17610f0
commit 148d3f0e7d
4 changed files with 180 additions and 121 deletions

View File

@@ -1,71 +1,38 @@
import { interpolate } from '@usebruno/common';
import { cloneDeep } from 'lodash';
import { isPlainObject, mapValues } from 'lodash-es';
export const interpolateHeaders = (headers = [], variables = {}) => {
return headers.map((header) => ({
...header,
name: interpolate(header.name, variables),
value: interpolate(header.value, variables)
}));
};
/**
* Traverses an object and interpolates any strings it finds.
*/
export const interpolateObject = (obj, variables) => {
const seen = new WeakSet();
export const interpolateBody = (body, variables = {}) => {
if (!body) return null;
const walk = (value) => {
if (value == null) return value;
const interpolatedBody = cloneDeep(body);
if (typeof value === 'string') {
return interpolate(value, variables);
}
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);
if (typeof value === 'object') {
if (seen.has(value)) {
throw new Error(
'Circular reference detected during interpolation.'
);
}
parsed = interpolate(parsed, variables, { escapeJSONStrings: true });
try {
const jsonObj = JSON.parse(parsed);
interpolatedBody.json = JSON.stringify(jsonObj, null, 2);
} catch {
interpolatedBody.json = parsed;
}
break;
seen.add(value);
}
case 'text':
interpolatedBody.text = interpolate(body.text, variables);
break;
if (Array.isArray(value)) {
return value.map(walk);
}
case 'xml':
interpolatedBody.xml = interpolate(body.xml, variables);
break;
if (isPlainObject(value)) {
return mapValues(value, walk);
}
case 'sparql':
interpolatedBody.sparql = interpolate(body.sparql, variables);
break;
return value;
};
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;
return walk(obj);
};

View File

@@ -1,35 +1,84 @@
import { interpolateHeaders, interpolateBody } from './interpolation';
import { interpolateObject } 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('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('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}');
const variables = {
host: 'api.example.com',
headerName: 'App-ID',
headerValue: 'val-123',
user: 'admin',
passVar: 'secure',
id: '99'
};
const result = interpolateObject(complexRequest, 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
}
});
});
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 leave the placeholder intact if the variable is missing', () => {
const variables = { known: 'value' };
const obj = {
field: '{{known}} and {{missing}}'
};
const result = interpolateObject(obj, variables);
expect(result).toEqual({
field: 'value and {{missing}}'
});
});
it('should interpolate text body', () => {
@@ -37,12 +86,12 @@ describe('interpolation utils', () => {
mode: 'text',
text: 'Hello {{name}}'
};
const result = interpolateBody(body, { name: 'World' });
const result = interpolateObject(body, { name: 'World' });
expect(result.text).toBe('Hello World');
});
it('should return null when body is null', () => {
expect(interpolateBody(null, { a: 1 })).toBeNull();
expect(interpolateObject(null, { a: 1 })).toBeNull();
});
});
});

View File

@@ -1,7 +1,7 @@
import { buildHarRequest } from 'utils/codegenerator/har';
import { getAuthHeaders } from 'utils/codegenerator/auth';
import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections/index';
import { interpolateHeaders, interpolateBody } from './interpolation';
import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections';
import { interpolateObject } from './interpolation';
import { get } from 'lodash';
const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => {
@@ -11,7 +11,11 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
const variables = getAllVariables(collection, item);
const request = item.request;
let request = item.request;
if (shouldInterpolate) {
request = interpolateObject(request, variables);
}
// Get the request tree path and merge headers
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
@@ -24,14 +28,6 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
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,
@@ -40,9 +36,8 @@ 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 result;
return snippet.convert(language.target, language.client);
} catch (error) {
console.error('Error generating code snippet:', error);
return 'Error generating code snippet';

View File

@@ -1,3 +1,5 @@
import { getAuthHeaders } from 'utils/codegenerator/auth';
jest.mock('httpsnippet', () => {
return {
HTTPSnippet: jest.fn().mockImplementation((harRequest) => ({
@@ -56,7 +58,9 @@ jest.mock('utils/collections/index', () => {
...collection?.processEnvVariables,
baseUrl: 'https://api.example.com',
apiKey: 'secret-key-123',
userId: '12345'
userId: '12345',
user: 'admin',
pass: 'secret123'
})),
getTreePathFromCollectionToItem: jest.fn(() => [])
};
@@ -146,11 +150,8 @@ describe('Snippet Generator - Simple Tests', () => {
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}'`);
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}'`);
});
it('should handle GET requests', () => {
@@ -203,11 +204,8 @@ 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/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`);
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}'`);
});
it('should handle complex nested JSON body', () => {
@@ -269,7 +267,7 @@ describe('Snippet Generator - Simple Tests', () => {
}
}, null, 2);
expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedComplexBody}'`);
expect(result).toBe(`curl -X POST https://api.example.com/data -H "Content-Type: application/json" -d '${expectedComplexBody}'`);
});
it('should handle errors gracefully', () => {
@@ -369,13 +367,9 @@ 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/{{endpoint}} -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`);
expect(result).toBe(`curl -X POST https://api.test.com/users -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`);
});
it('should NOT interpolate when shouldInterpolate is false', () => {
@@ -424,7 +418,61 @@ 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', () => {
const item = {
request: {
method: 'GET',
url: 'https://api.example.com',
auth: {
mode: 'basic',
basic: {
username: '{{user}}',
password: '{{pass}}'
}
}
}
};
const collection = {
root: {
request: {
vars: {
req: [
{ name: 'user', value: 'admin', enabled: true },
{ name: 'pass', value: 'secret123', enabled: true }
]
}
}
}
};
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
const { getAuthHeaders: actualGetAuthHeaders } = jest.requireActual('utils/codegenerator/auth');
getAuthHeaders.mockImplementation(actualGetAuthHeaders);
const language = { target: 'shell', client: 'curl' };
generateSnippet({
language,
item,
collection,
shouldInterpolate: true
});
const harRequest = mockedHTTPSnippet.mock.calls[0][0];
// "admin:secret123" encoded is "YWRtaW46c2VjcmV0MTIz"
expect(harRequest.headers).toContainEqual(
expect.objectContaining({
name: 'Authorization',
value: 'Basic YWRtaW46c2VjcmV0MTIz'
})
);
});
});