diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js index 659eaf53f..a5a867f2b 100644 --- a/packages/bruno-cli/src/runner/interpolate-vars.js +++ b/packages/bruno-cli/src/runner/interpolate-vars.js @@ -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) => { diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index 9588e8fed..83c7ab86c 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -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'; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 29f19155e..79c54c59e 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -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); } diff --git a/packages/bruno-cli/tests/runner/prepare-request.spec.js b/packages/bruno-cli/tests/runner/prepare-request.spec.js index 61dd143fd..c48141afd 100644 --- a/packages/bruno-cli/tests/runner/prepare-request.spec.js +++ b/packages/bruno-cli/tests/runner/prepare-request.spec.js @@ -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('{}'); + }); + }); }); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 222794d9b..e6a612cac 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -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 diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js index c8ce4dfa0..61c9fbd71 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js @@ -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. diff --git a/packages/bruno-electron/tests/network/prepare-request.spec.js b/packages/bruno-electron/tests/network/prepare-request.spec.js index 89ba9c520..a0247b9f5 100644 --- a/packages/bruno-electron/tests/network/prepare-request.spec.js +++ b/packages/bruno-electron/tests/network/prepare-request.spec.js @@ -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}}}'); + }); + }); }); diff --git a/packages/bruno-tests/collection/graphql/variable-interpolation.bru b/packages/bruno-tests/collection/graphql/variable-interpolation.bru new file mode 100644 index 000000000..1a0c7ee53 --- /dev/null +++ b/packages/bruno-tests/collection/graphql/variable-interpolation.bru @@ -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\"}}"); + }); +}