diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js index 8fe747389..3b1cc6109 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js @@ -7,8 +7,10 @@ import Dropdown from '../../Dropdown'; const GraphQLSchemaActions = ({ item, collection, onSchemaLoad, toggleDocs }) => { const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', ''); + const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', ''); + const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', ''); const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid); - const request = item.draft ? item.draft.request : item.request; + const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid }; let { schema, diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index b56cd4ac0..c97b7f63c 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -9,7 +9,7 @@ const contentDispositionParser = require('content-disposition'); const mime = require('mime-types'); const FormData = require('form-data'); const { ipcMain } = require('electron'); -const { each, get, extend, cloneDeep } = require('lodash'); +const { each, get, extend, cloneDeep, merge } = require('lodash'); const { NtlmClient } = require('axios-ntlm'); const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js'); const { interpolateString } = require('./interpolate-string'); @@ -24,7 +24,7 @@ const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseData const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem'); const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies'); const { createFormData } = require('../../utils/form-data'); -const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars } = require('../../utils/collection'); +const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars } = require('../../utils/collection'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials } = require('../../utils/oauth2'); const { preferencesUtil } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); @@ -317,6 +317,77 @@ const configureRequest = async ( return axiosInstance; }; +const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, collection) => { + try { + const requestTreePath = getTreePathFromCollectionToItem(collection, _request); + // Create a clone of the request to avoid mutating the original + const resolvedRequest = cloneDeep(_request); + // mergeVars modifies the request in place, but we'll assign it to ensure consistency + mergeVars(collection, resolvedRequest, requestTreePath); + const envVars = getEnvVars(environment); + + const globalEnvironmentVars = collection.globalEnvironmentVariables; + const folderVars = resolvedRequest.folderVariables; + const requestVariables = resolvedRequest.requestVariables; + const collectionVariables = resolvedRequest.collectionVariables; + const runtimeVars = collection.runtimeVariables; + + // Precedence: runtimeVars > requestVariables > folderVars > envVars > collectionVariables > globalEnvironmentVars + const resolvedVars = merge( + {}, + globalEnvironmentVars, + collectionVariables, + envVars, + folderVars, + requestVariables, + runtimeVars + ); + + const collectionRoot = get(collection, 'root', {}); + const request = prepareGqlIntrospectionRequest(endpoint, resolvedVars, _request, collectionRoot); + + request.timeout = preferencesUtil.getRequestTimeout(); + + if (!preferencesUtil.shouldVerifyTls()) { + request.httpsAgent = new https.Agent({ + rejectUnauthorized: false + }); + } + + const collectionPath = collection.pathname; + const processEnvVars = getProcessEnvVars(collection.uid); + + const axiosInstance = await configureRequest( + collection.uid, + request, + envVars, + collection.runtimeVariables, + processEnvVars, + collectionPath + ); + + const response = await axiosInstance(request); + + return { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: response.data + }; + } catch (error) { + if (error.response) { + return { + status: error.response.status, + statusText: error.response.statusText, + headers: error.response.headers, + data: error.response.data + }; + } + + return Promise.reject(error); + } +}; + const registerNetworkIpc = (mainWindow) => { const onConsoleLog = (type, args) => { console[type](...args); @@ -804,84 +875,8 @@ const registerNetworkIpc = (mainWindow) => { }); }); - ipcMain.handle('fetch-gql-schema', async (event, endpoint, environment, _request, collection) => { - try { - const envVars = getEnvVars(environment); - const collectionRoot = get(collection, 'root', {}); - const request = prepareGqlIntrospectionRequest(endpoint, envVars, _request, collectionRoot); - - request.timeout = preferencesUtil.getRequestTimeout(); - - if (!preferencesUtil.shouldVerifyTls()) { - request.httpsAgent = new https.Agent({ - rejectUnauthorized: false - }); - } - - const requestUid = uuid(); - const collectionPath = collection.pathname; - const collectionUid = collection.uid; - const runtimeVariables = collection.runtimeVariables; - const processEnvVars = getProcessEnvVars(collectionUid); - const brunoConfig = getBrunoConfig(collection.uid); - const scriptingConfig = get(brunoConfig, 'scripts', {}); - scriptingConfig.runtime = getJsSandboxRuntime(collection); - - await runPreRequest( - request, - requestUid, - envVars, - collectionPath, - collection, - collectionUid, - runtimeVariables, - processEnvVars, - scriptingConfig - ); - - interpolateVars(request, envVars, collection.runtimeVariables, processEnvVars); - const axiosInstance = await configureRequest( - collection.uid, - request, - envVars, - collection.runtimeVariables, - processEnvVars, - collectionPath - ); - const response = await axiosInstance(request); - - await runPostResponse( - request, - response, - requestUid, - envVars, - collectionPath, - collection, - collectionUid, - runtimeVariables, - processEnvVars, - scriptingConfig - ); - - return { - status: response.status, - statusText: response.statusText, - headers: response.headers, - data: response.data - }; - } catch (error) { - if (error.response) { - return { - status: error.response.status, - statusText: error.response.statusText, - headers: error.response.headers, - data: error.response.data - }; - } - - return Promise.reject(error); - } - }); + // handler for fetch-gql-schema + ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler) ipcMain.handle( 'renderer:run-collection-folder', @@ -1342,3 +1337,4 @@ const registerNetworkIpc = (mainWindow) => { module.exports = registerNetworkIpc; module.exports.configureRequest = configureRequest; module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig; +module.exports.fetchGqlSchemaHandler = fetchGqlSchemaHandler; diff --git a/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js b/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js index c137c4b33..158a71dc6 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js @@ -3,9 +3,9 @@ const { interpolate } = require('@usebruno/common'); const { getIntrospectionQuery } = require('graphql'); const { setAuthHeaders } = require('./prepare-request'); -const prepareGqlIntrospectionRequest = (endpoint, envVars, request, collectionRoot) => { +const prepareGqlIntrospectionRequest = (endpoint, resolvedVars, request, collectionRoot) => { if (endpoint && endpoint.length) { - endpoint = interpolate(endpoint, envVars); + endpoint = interpolate(endpoint, resolvedVars); } const queryParams = { @@ -16,7 +16,7 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars, request, collectionRo method: 'POST', url: endpoint, headers: { - ...mapHeaders(request.headers, get(collectionRoot, 'request.headers', [])), + ...mapHeaders(request.headers, get(collectionRoot, 'request.headers', []), resolvedVars), Accept: 'application/json', 'Content-Type': 'application/json' }, @@ -26,19 +26,20 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars, request, collectionRo return setAuthHeaders(axiosRequest, request, collectionRoot); }; -const mapHeaders = (requestHeaders, collectionHeaders) => { +const mapHeaders = (requestHeaders, collectionHeaders, resolvedVars) => { const headers = {}; - each(requestHeaders, (h) => { + // Add collection headers first + each(collectionHeaders, (h) => { if (h.enabled) { - headers[h.name] = h.value; + headers[h.name] = interpolate(h.value, resolvedVars); } }); - // collection headers - each(collectionHeaders, (h) => { + // Then add request headers, which will overwrite if names overlap + each(requestHeaders, (h) => { if (h.enabled) { - headers[h.name] = h.value; + headers[h.name] = interpolate(h.value, resolvedVars); } }); diff --git a/packages/bruno-electron/tests/network/fetch-gql-schema-handler.spec.js b/packages/bruno-electron/tests/network/fetch-gql-schema-handler.spec.js new file mode 100644 index 000000000..8831ba48b --- /dev/null +++ b/packages/bruno-electron/tests/network/fetch-gql-schema-handler.spec.js @@ -0,0 +1,371 @@ +const prepareGqlIntrospectionRequest = require('../../src/ipc/network/prepare-gql-introspection-request'); +const { fetchGqlSchemaHandler } = require('../../src/ipc/network'); + +// Mock only the prepare-gql-introspection-request to avoid network calls +jest.mock('../../src/ipc/network/prepare-gql-introspection-request', () => { + return jest.fn().mockImplementation((endpoint, vars, request, root) => { + return { + url: endpoint, + method: 'POST', + headers: request?.headers || {}, + data: { + query: '{ __schema { types { name } } }' + } + }; + }); +}); + +describe('fetchGqlSchemaHandler - variable precedence', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should override global environment variables with environment variables', async () => { + const endpoint = 'https://example.com/'; + const environment = { + variables: [ + { name: 'SHARED_VAR', value: 'env-value', enabled: true } + ] + }; + const request = { + uid: 'test-request', + vars: { + req: [] // No request variables + } + }; + const collection = { + uid: 'test-collection', + pathname: '/test', + runtimeVariables: {}, + globalEnvironmentVariables: { + SHARED_VAR: 'global-value' + }, + items: [ + { + uid: 'test-request', + request: { + vars: { + req: [] // No request variables + } + } + } + ], + root: { + request: { + headers: [], + vars: { + req: [] // No collection variables + } + } + } + }; + + await fetchGqlSchemaHandler(null, endpoint, environment, request, collection); + + expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + SHARED_VAR: 'env-value' + }), + request, + collection.root + ); + }); + + it('should override environment variables with folder-level variables', async () => { + const endpoint = 'https://example.com/'; + const environment = { + variables: [ + { name: 'SHARED_VAR', value: 'env-value', enabled: true } + ] + }; + const request = { + uid: 'test-request', + vars: { + req: [] // No request variables + } + }; + const collection = { + uid: 'test-collection', + pathname: '/test', + runtimeVariables: {}, + globalEnvironmentVariables: {}, + items: [ + { + uid: 'test-folder', + type: 'folder', + root: { + request: { + vars: { + req: [ + { name: 'SHARED_VAR', value: 'folder-value', enabled: true } + ] + } + } + }, + items: [ + { + uid: 'test-request', + request: { + vars: { + req: [] // No request variables + } + } + } + ] + } + ], + root: { + request: { + headers: [], + vars: { + req: [] // No collection variables + } + } + } + }; + + await fetchGqlSchemaHandler(null, endpoint, environment, request, collection); + + expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + SHARED_VAR: 'folder-value' + }), + request, + collection.root + ); + }); + + it('should override folder-level variables with request variables', async () => { + const endpoint = 'https://example.com/'; + const environment = { + variables: [] + }; + const request = { + uid: 'test-request', + vars: { + req: [ + { name: 'SHARED_VAR', value: 'request-value', enabled: true } + ] + } + }; + const collection = { + uid: 'test-collection', + pathname: '/test', + runtimeVariables: {}, + globalEnvironmentVariables: {}, + items: [ + { + uid: 'test-folder', + type: 'folder', + root: { + request: { + vars: { + req: [ + { name: 'SHARED_VAR', value: 'folder-value', enabled: true } + ] + } + } + }, + items: [ + { + uid: 'test-request', + request: { + vars: { + req: [ + { name: 'SHARED_VAR', value: 'request-value', enabled: true } + ] + } + } + } + ] + } + ], + root: { + request: { + headers: [], + vars: { + req: [] // No collection variables + } + } + } + }; + + await fetchGqlSchemaHandler(null, endpoint, environment, request, collection); + + expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + SHARED_VAR: 'request-value' + }), + request, + collection.root + ); + }); + + it('should override global environment variables with collection variables', async () => { + const endpoint = 'https://example.com/'; + const environment = { + variables: [] + }; + const request = { + uid: 'test-request', + vars: { + req: [] // No request variables + } + }; + const collection = { + uid: 'test-collection', + pathname: '/test', + runtimeVariables: {}, + globalEnvironmentVariables: { + SHARED_VAR: 'global-value' + }, + items: [ + { + uid: 'test-request', + request: { + vars: { + req: [] // No request variables + } + } + } + ], + root: { + request: { + headers: [], + vars: { + req: [ + { name: 'SHARED_VAR', value: 'collection-value', enabled: true } + ] + } + } + } + }; + + await fetchGqlSchemaHandler(null, endpoint, environment, request, collection); + + expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + SHARED_VAR: 'collection-value' + }), + request, + collection.root + ); + }); + + it('should override collection variables with environment variables', async () => { + const endpoint = 'https://example.com/'; + const environment = { + variables: [ + { name: 'SHARED_VAR', value: 'env-value', enabled: true } + ] + }; + const request = { + uid: 'test-request', + vars: { + req: [] // No request variables + } + }; + const collection = { + uid: 'test-collection', + pathname: '/test', + runtimeVariables: {}, + globalEnvironmentVariables: {}, + items: [ + { + uid: 'test-request', + request: { + vars: { + req: [] // No request variables + } + } + } + ], + root: { + request: { + headers: [], + vars: { + req: [ + { name: 'SHARED_VAR', value: 'collection-value', enabled: true } + ] + } + } + } + }; + + await fetchGqlSchemaHandler(null, endpoint, environment, request, collection); + + expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + SHARED_VAR: 'env-value' + }), + request, + collection.root + ); + }); + + it('should override request variables with runtime variables', async () => { + const endpoint = 'https://example.com/'; + const environment = { + variables: [] + }; + + const request = { + uid: 'test-request', + vars: { + req: [ + { name: 'SHARED_VAR', value: 'request-value', enabled: true } + ] + } + }; + + const collection = { + uid: 'test-collection', + pathname: '/test', + runtimeVariables: { + SHARED_VAR: 'runtime-value' + }, + items: [ + { + uid: 'test-request', + request: { + vars: { + req: [ + { name: 'SHARED_VAR', value: 'request-value', enabled: true } + ] + } + } + } + ], + root: { + request: { + headers: [], + vars: { + req: [] // No collection variables + } + } + } + }; + + await fetchGqlSchemaHandler(null, endpoint, environment, request, collection); + + expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + SHARED_VAR: 'runtime-value' + }), + request, + collection.root + ); + }) +}); + + diff --git a/packages/bruno-electron/tests/network/prepare-gql-introspection-request.spec.js b/packages/bruno-electron/tests/network/prepare-gql-introspection-request.spec.js new file mode 100644 index 000000000..2eacde679 --- /dev/null +++ b/packages/bruno-electron/tests/network/prepare-gql-introspection-request.spec.js @@ -0,0 +1,66 @@ +const prepareGqlIntrospectionRequest = require('../../src/ipc/network/prepare-gql-introspection-request'); + +describe('prepareGqlIntrospectionRequest', () => { + const createBasicSetup = () => ({ + endpoint: 'https://example.com/', + request: { + headers: [] + }, + collectionRoot: { + request: { + headers: [] + } + } + }); + + it('should handle environment variables in headers', () => { + const setup = createBasicSetup(); + setup.request.headers = [ + { name: 'Authorization', value: 'Bearer {{AUTH_TOKEN}}', enabled: true } + ]; + const vars = { + AUTH_TOKEN: 'token-value' + }; + + const result = prepareGqlIntrospectionRequest(setup.endpoint, vars, setup.request, setup.collectionRoot); + + expect(result.headers['Authorization']).toBe('Bearer token-value'); + expect(result.method).toBe('POST'); + expect(result.url).toBe(setup.endpoint); + }); + + it('should override collection headers with request headers', () => { + const setup = createBasicSetup(); + setup.collectionRoot.request.headers = [ + { name: 'X-Header', value: 'collection-value', enabled: true } + ]; + setup.request.headers = [ + { name: 'X-Header', value: 'request-value', enabled: true } + ]; + + const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot); + + expect(result.headers['X-Header']).toBe('request-value'); + }); + + it('should handle enabled and disabled headers', () => { + const setup = createBasicSetup(); + setup.request.headers = [ + { name: 'X-Enabled', value: 'enabled', enabled: true }, + { name: 'X-Disabled', value: 'disabled', enabled: false } + ]; + + const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot); + + expect(result.headers['X-Enabled']).toBe('enabled'); + expect(result.headers['X-Disabled']).toBeUndefined(); + }); + + it('should always include required GraphQL headers', () => { + const setup = createBasicSetup(); + const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot); + expect(result.headers['Accept']).toBe('application/json'); + expect(result.headers['Content-Type']).toBe('application/json'); + }); + +}); \ No newline at end of file