fix: graphQL variables interpolation consistency (UI and CLI) (#7049)

* feat: enhance GraphQL request handling with variable interpolation
This commit is contained in:
Sanjai Kumar
2026-02-12 13:48:35 +05:30
committed by GitHub
parent e1827080dd
commit 836c2b9ace
8 changed files with 187 additions and 32 deletions

View File

@@ -67,40 +67,47 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
});
const contentType = getContentType(request.headers);
const isGraphqlRequest = request.mode === 'graphql';
if (contentType.includes('json')) {
// Skip interpolation if data is a Buffer (e.g., gzip-compressed data)
if (typeof request.data === 'object' && !Buffer.isBuffer(request.data)) {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed, { escapeJSONStrings: true });
request.data = JSON.parse(parsed);
} catch (err) {}
}
// GraphQL: interpolate query and variables in place. We do not stringify the whole body and interpolate that, because variables is a JSON string. Full-body stringify would nest it and double-escape any {{var}} inside.
if (isGraphqlRequest && request.data && typeof request.data === 'object') {
request.data.query = _interpolate(request.data.query, { escapeJSONStrings: true });
request.data.variables = _interpolate(request.data.variables, { escapeJSONStrings: true });
}
if (typeof request.data === 'string') {
if (request?.data?.length) {
request.data = _interpolate(request.data, { escapeJSONStrings: true });
// Skip body interpolation for GraphQL requests.
if (!isGraphqlRequest) {
if (contentType.includes('json') && !Buffer.isBuffer(request.data)) {
if (typeof request.data === 'string') {
if (request?.data?.length) {
request.data = _interpolate(request.data, { escapeJSONStrings: true });
}
} else if (typeof request.data === 'object') {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed, { escapeJSONStrings: true });
request.data = JSON.parse(parsed);
} catch (err) {}
}
}
} else if (contentType === 'application/x-www-form-urlencoded') {
if (request.data && Array.isArray(request.data)) {
request.data = request.data.map((d) => ({
...d,
value: _interpolate(d?.value)
}));
}
} else if (contentType === 'multipart/form-data') {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
try {
request.data = request?.data?.map((d) => ({
} else if (contentType === 'application/x-www-form-urlencoded') {
if (request.data && Array.isArray(request.data)) {
request.data = request.data.map((d) => ({
...d,
value: _interpolate(d?.value)
}));
} catch (err) {}
}
} else if (contentType === 'multipart/form-data') {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
try {
request.data = request?.data?.map((d) => ({
...d,
value: _interpolate(d?.value)
}));
} catch (err) {}
}
} else {
request.data = _interpolate(request.data);
}
} else {
request.data = _interpolate(request.data);
}
each(request?.pathParams, (param) => {

View File

@@ -45,7 +45,8 @@ const prepareRequest = async (item = {}, collection = {}) => {
tags: item.tags || [],
pathParams: request.params?.filter((param) => param.type === 'path'),
settings: item.settings,
responseType: 'arraybuffer'
responseType: 'arraybuffer',
mode: request.body?.mode
};
const collectionRoot = collection?.draft?.root || collection?.root || {};
@@ -371,7 +372,8 @@ const prepareRequest = async (item = {}, collection = {}) => {
if (request.body.mode === 'graphql') {
const graphqlQuery = {
query: get(request, 'body.graphql.query'),
variables: JSON.parse(decomment(get(request, 'body.graphql.variables') || '{}'))
// Parse variables only after interpolation (github.com/usebruno/bruno/issues/884)
variables: decomment(get(request, 'body.graphql.variables') || '{}')
};
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json';

View File

@@ -308,6 +308,16 @@ const runSingleRequest = async function (
// interpolate variables inside request
interpolateVars(request, envVariables, runtimeVariables, processEnvVars);
// if this is a graphql request, parse the variables, only after interpolation
// https://github.com/usebruno/bruno/issues/884
if (request.mode === 'graphql' && typeof request.data?.variables === 'string') {
try {
request.data.variables = JSON.parse(request.data.variables);
} catch (err) {
throw new Error(`Failed to parse GraphQL variables: ${err.message}`);
}
}
if (request.settings?.encodeUrl) {
request.url = encodeUrl(request.url);
}

View File

@@ -600,4 +600,50 @@ describe('prepare-request: prepareRequest', () => {
expect(readFileSyncSpy).not.toHaveBeenCalled();
});
});
describe('GraphQL request', () => {
it('keeps variables as string for interpolation', async () => {
const item = {
request: {
method: 'POST',
headers: [],
params: [],
url: 'https://example.com',
body: {
mode: 'graphql',
graphql: {
query: 'query { x }',
variables: '{"apiPermissions": {{permissionsJSON}}}'
}
}
}
};
const result = await prepareRequest(item);
expect(result.mode).toBe('graphql');
expect(result.data).toMatchObject({ query: 'query { x }' });
expect(typeof result.data.variables).toBe('string');
expect(result.data.variables).toBe('{"apiPermissions": {{permissionsJSON}}}');
});
it('defaults variables to "{}" when missing', async () => {
const item = {
request: {
method: 'POST',
headers: [],
params: [],
url: 'https://example.com',
body: {
mode: 'graphql',
graphql: {
query: 'query { x }',
variables: undefined
}
}
}
};
const result = await prepareRequest(item);
expect(typeof result.data.variables).toBe('string');
expect(result.data.variables).toBe('{}');
});
});
});

View File

@@ -540,8 +540,12 @@ const registerNetworkIpc = (mainWindow) => {
// if this is a graphql request, parse the variables, only after interpolation
// https://github.com/usebruno/bruno/issues/884
if (request.mode === 'graphql') {
request.data.variables = JSON.parse(request.data.variables);
if (request.mode === 'graphql' && typeof request.data?.variables === 'string') {
try {
request.data.variables = JSON.parse(request.data.variables);
} catch (err) {
throw new Error(`Failed to parse GraphQL variables: ${err.message}`);
}
}
// stringify the request url encoded params

View File

@@ -74,6 +74,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
});
const contentType = getContentType(request.headers);
const isGraphqlRequest = request.mode === 'graphql';
if (isGrpcRequest) {
const jsonDoc = JSON.stringify(request.body);
@@ -103,7 +104,13 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
});
}
if (typeof contentType === 'string') {
// GraphQL: interpolate query and variables in place. We do not stringify the whole body and interpolate that, because variables is a JSON string. Full-body stringify would nest it and double-escape any {{var}} inside.
if (isGraphqlRequest && request.data && typeof request.data === 'object') {
request.data.query = _interpolate(request.data.query, { escapeJSONStrings: true });
request.data.variables = _interpolate(request.data.variables, { escapeJSONStrings: true });
}
if (typeof contentType === 'string' && !isGraphqlRequest) {
/*
We explicitly avoid interpolating buffer values because the file content is read as a buffer object in raw body mode.
Even if the selected file's content type is JSON, this prevents the buffer object from being interpolated.

View File

@@ -39,4 +39,29 @@ describe('prepare-request: prepareRequest', () => {
expect(result.headers['content-type']).toEqual('application/json');
});
});
describe('GraphQL request', () => {
it('keeps variables as string for interpolation', async () => {
const item = {
request: {
method: 'POST',
headers: [],
params: [],
url: 'https://example.com',
body: {
mode: 'graphql',
graphql: {
query: 'query { x }',
variables: '{"apiPermissions": {{permissionsJSON}}}'
}
}
}
};
const result = await prepareRequest(item);
expect(result.mode).toBe('graphql');
expect(result.data).toMatchObject({ query: 'query { x }' });
expect(typeof result.data.variables).toBe('string');
expect(result.data.variables).toBe('{"apiPermissions": {{permissionsJSON}}}');
});
});
});

View File

@@ -0,0 +1,54 @@
meta {
name: variables interpolation
type: graphql
seq: 3
}
post {
url: {{host}}/api/echo/json
body: graphql
auth: none
}
body:graphql {
query { __typename }
}
body:graphql:vars {
{
"my_json": "{{my_json}}"
}
}
assert {
res.status: eq 200
}
script:pre-request {
const testData = {
a: [1,2,3],
b: {
c: "test",
d: "another value"
}
};
// Single escaping
let cv = JSON.stringify(testData).replace(/"/g, '\\"');
bru.setVar("my_json", cv)
}
script:post-response {
bru.deleteVar("my_json")
}
tests {
test("GraphQL variables with nested object and array are interpolated then sent as parsed object", function() {
const body = res.getBody();
expect(body).to.have.property("variables");
expect(body.variables).to.be.an("object");
expect(body.variables).to.have.property("my_json");
expect(body.variables.my_json).to.eql("{\"a\":[1,2,3],\"b\":{\"c\":\"test\",\"d\":\"another value\"}}");
});
}