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
2025-12-17 22:32:16 -05:00
committed by sanjai
parent 154c45d87d
commit c94785f521
4 changed files with 181 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,8 +1,9 @@
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';
import interpolateVars from 'bruno/src/ipc/network/interpolate-vars';
const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => {
try {
@@ -11,7 +12,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 +29,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 +37,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'
})
);
});
});