diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index 349efbf06..170db187c 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -91,7 +91,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven // Always show raw const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }]; - if (!mode || !contentType) return; + if (!mode || !contentType) return allowedPreviewModes; if (mode?.includes('html') && typeof data === 'string') { allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() }); diff --git a/packages/bruno-electron/src/ipc/network/axios-instance.js b/packages/bruno-electron/src/ipc/network/axios-instance.js index 61c7320ba..4960d6498 100644 --- a/packages/bruno-electron/src/ipc/network/axios-instance.js +++ b/packages/bruno-electron/src/ipc/network/axios-instance.js @@ -101,28 +101,28 @@ function makeAxiosInstance({ const url = URL.parse(config.url); config.metadata = config.metadata || {}; config.metadata.startTime = new Date().getTime(); - config.metadata.timeline = config.metadata.timeline || []; + const timeline = config.metadata.timeline || [] // Add initial request details to the timeline - config.metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'info', message: `Preparing request to ${config.url}`, }); - config.metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'info', message: `Current time is ${new Date().toISOString()}`, }); // Add request method and headers - config.metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'request', message: `${config.method.toUpperCase()} ${config.url}`, }); Object.entries(config.headers).forEach(([key, value]) => { - config.metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'requestHeader', message: `${key}: ${value}`, @@ -131,13 +131,8 @@ function makeAxiosInstance({ // Add request data if available if (config.data) { - let requestData; - try { - requestData = typeof config.data === 'string' ? config.data : JSON.stringify(config.data, null, 2); - } catch (err) { - requestData = config.data.toString(); - } - config.metadata.timeline.push({ + let requestData = typeof config.data === 'string' ? config.data : JSON.stringify(config.data, null, 2); + timeline.push({ timestamp: new Date(), type: 'requestData', message: requestData, @@ -164,16 +159,26 @@ function makeAxiosInstance({ ...httpsAgentRequestFields, keepAlive: true, }; - - // Now call setupProxyAgents and pass the timeline - setupProxyAgents({ - requestConfig: config, - proxyMode: proxyMode, // 'on', 'off', or 'system', depending on your settings - proxyConfig: proxyConfig, - httpsAgentRequestFields: agentOptions, - interpolationOptions: interpolationOptions, // Provide your interpolation options - timeline: config.metadata.timeline, - }); + + try { + // Now call setupProxyAgents and pass the timeline + setupProxyAgents({ + requestConfig: config, + proxyMode: proxyMode, // 'on', 'off', or 'system', depending on your settings + proxyConfig: proxyConfig, + httpsAgentRequestFields: agentOptions, + interpolationOptions: interpolationOptions, // Provide your interpolation options + timeline, + }); + } + catch(err) { + timeline.push({ + timestamp: new Date(), + type: 'error', + message: err?.message, + }); + } + config.metadata.timeline = timeline; return config; }); @@ -181,93 +186,95 @@ function makeAxiosInstance({ instance.interceptors.response.use( (response) => { + let timeline; const end = Date.now(); const start = response.config.headers['request-start-time']; response.headers['request-duration'] = end - start; redirectCount = 0; const config = response.config; - const metadata = config.metadata; - const duration = end - metadata.startTime; + timeline = config?.metadata?.timeline || [] + const duration = end - config?.metadata.startTime; - const httpVersion = response.request?.res?.httpVersion || '1.1'; - metadata.timeline.push({ - timestamp: new Date(), - type: 'response', - message: `HTTP/${httpVersion} ${response.status} ${response.statusText}`, - }); - - if (httpVersion.startsWith('2')) { - metadata.timeline.push({ + const httpVersion = response?.request?.res?.httpVersion || response?.httpVersion; + if (httpVersion?.startsWith('2')) { + timeline.push({ timestamp: new Date(), type: 'info', message: `Using HTTP/2, server supports multiplexing`, }); } - - metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'response', - message: `HTTP/${response.httpVersion || '1.1'} ${response.status} ${response.statusText}`, + message: `HTTP/${httpVersion || '1.1'} ${response.status} ${response.statusText}`, }); + Object.entries(response.headers).forEach(([key, value]) => { - metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'responseHeader', message: `${key}: ${value}`, }); }); - metadata.timeline.push({ + + timeline.push({ timestamp: new Date(), type: 'info', message: `Request completed in ${duration} ms`, }); - - // Attach the timeline to the response - response.timeline = metadata.timeline; - + response.timeline = timeline; return response; }, (error) => { + const config = error.config; + const timeline = config?.metadata?.timeline || []; + timeline?.push({ + timestamp: new Date(), + type: 'error', + message: 'there was an error executing the request!' + }); if (error.response) { const end = Date.now(); const start = error.config.headers['request-start-time']; error.response.headers['request-duration'] = end - start; - const config = error.config; - const metadata = config.metadata; - const duration = end - metadata.startTime; - + const duration = end - config?.metadata?.startTime; if (error.response && redirectResponseCodes.includes(error.response.status)) { - metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'response', message: `HTTP/${error.response.httpVersion || '1.1'} ${error.response.status} ${error.response.statusText}`, }); Object.entries(error.response.headers).forEach(([key, value]) => { - metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'responseHeader', message: `${key}: ${value}`, }); }); - metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'info', message: `Request completed in ${duration} ms`, }); // Attach the timeline to the response - error.response.timeline = metadata.timeline; + error.response.timeline = timeline; if (redirectCount >= requestMaxRedirects) { - const dataBuffer = Buffer.from(error.response.data); - + const errorResponseData = error.response.data; + const dataBuffer = Buffer.isBuffer(errorResponseData) ? errorResponseData : Buffer.from(errorResponseData); + timeline?.push({ + timestamp: new Date(), + type: 'error', + message: safeStringifyJSON(errorResponseData?.toString?.()) + }); return { status: error.response.status, statusText: error.response.statusText, headers: error.response.headers, - data: error.response.data, - dataBuffer: dataBuffer.toString('base64'), + data: errorResponseData?.toString?.(), + dataBuffer: dataBuffer, size: Buffer.byteLength(dataBuffer), duration: error.response.headers.get('request-duration') ?? 0, timeline: error.response.timeline @@ -285,7 +292,7 @@ function makeAxiosInstance({ // It's a relative URL, resolve it against the original URL redirectUrl = URL.resolve(error.config.url, locationHeader); - metadata.timeline.push({ + timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving relative redirect URL: ${locationHeader} → ${redirectUrl}`, @@ -318,26 +325,74 @@ function makeAxiosInstance({ proxyConfig, httpsAgentRequestFields, interpolationOptions, - timeline: metadata.timeline + timeline }); + requestConfig.metadata.timeline = timeline; // Make the redirected request return instance(requestConfig); } + else { + const errorResponseData = error.response.data; + const dataBuffer = Buffer.isBuffer(errorResponseData) ? errorResponseData : Buffer.from(errorResponseData); + Object.entries(error?.response?.headers || {}).forEach(([key, value]) => { + timeline.push({ + timestamp: new Date(), + type: 'responseHeader', + message: `${key}: ${value}`, + }); + }); + timeline?.push({ + timestamp: new Date(), + type: 'error', + message: safeStringifyJSON(errorResponseData?.toString?.()) + }); + error?.cause && timeline?.push({ + timestamp: new Date(), + type: 'error', + message: safeStringifyJSON(error?.cause) + }); + error?.errors && timeline?.push({ + timestamp: new Date(), + type: 'error', + message: safeStringifyJSON(error?.errors) + }); + return { + status: error.response.status, + statusText: error.response.statusText, + headers: error.response.headers, + data: errorResponseData?.toString?.(), + dataBuffer: dataBuffer, + size: Buffer.byteLength(dataBuffer), + duration: error.response.headers.get('request-duration') ?? 0, + timeline + }; + } } else if (error?.code) { - let metadata = error?.config?.metadata; - metadata?.timeline?.push({ + Object.entries(error?.response?.headers || {}).forEach(([key, value]) => { + timeline.push({ + timestamp: new Date(), + type: 'responseHeader', + message: `${key}: ${value}`, + }); + }); + timeline?.push({ timestamp: new Date(), type: 'error', - message: `${safeStringifyJSON(error?.cause) || ''}\n${safeStringifyJSON(error?.errors) || ''}` + message: safeStringifyJSON(error?.cause) + }); + timeline?.push({ + timestamp: new Date(), + type: 'error', + message: safeStringifyJSON(error?.errors) }); return { status: '-', statusText: error.code, headers: error?.config?.headers, data: 'request failed, check timeline network logs', - timeline: metadata.timeline + timeline }; } return Promise.reject(error); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 9a27278b0..159df0443 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -3,7 +3,6 @@ const https = require('https'); const axios = require('axios'); const path = require('path'); const decomment = require('decomment'); -const iconv = require('iconv-lite'); const fs = require('fs'); const tls = require('tls'); const contentDispositionParser = require('content-disposition'); @@ -21,7 +20,7 @@ const { prepareRequest } = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); const { makeAxiosInstance } = require('./axios-instance'); const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token'); -const { uuid, safeStringifyJSON, safeParseJSON } = require('../../utils/common'); +const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse } = require('../../utils/common'); const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem'); const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies'); const { createFormData } = require('../../utils/form-data'); @@ -328,33 +327,6 @@ const configureRequest = async ( return axiosInstance; }; -const parseDataFromResponse = (response, disableParsingResponseJson = false) => { - // Parse the charset from content type: https://stackoverflow.com/a/33192813 - const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(response.headers['content-type'] || ''); - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#using_exec_with_regexp_literals - const charsetValue = charsetMatch?.[1]; - const dataBuffer = Buffer.from(response.data); - // Overwrite the original data for backwards compatibility - let data; - if (iconv.encodingExists(charsetValue)) { - data = iconv.decode(dataBuffer, charsetValue); - } else { - data = iconv.decode(dataBuffer, 'utf-8'); - } - // Try to parse response to JSON, this can quietly fail - try { - // Filter out ZWNBSP character - // https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d - data = data.replace(/^\uFEFF/, ''); - if (!disableParsingResponseJson) { - data = JSON.parse(data); - } - } catch { } - - return { data, dataBuffer }; -}; - - const registerNetworkIpc = (mainWindow) => { const onConsoleLog = (type, args) => { console[type](...args); diff --git a/packages/bruno-electron/src/utils/common.js b/packages/bruno-electron/src/utils/common.js index 50b17bb38..71bdfa09e 100644 --- a/packages/bruno-electron/src/utils/common.js +++ b/packages/bruno-electron/src/utils/common.js @@ -1,4 +1,5 @@ const { customAlphabet } = require('nanoid'); +const iconv = require('iconv-lite'); // a customized version of nanoid without using _ and - const uuid = () => { @@ -85,6 +86,32 @@ const flattenDataForDotNotation = (data) => { return result; }; +const parseDataFromResponse = (response, disableParsingResponseJson = false) => { + // Parse the charset from content type: https://stackoverflow.com/a/33192813 + const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(response.headers['content-type'] || ''); + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#using_exec_with_regexp_literals + const charsetValue = charsetMatch?.[1]; + const dataBuffer = Buffer.from(response.data); + // Overwrite the original data for backwards compatibility + let data; + if (iconv.encodingExists(charsetValue)) { + data = iconv.decode(dataBuffer, charsetValue); + } else { + data = iconv.decode(dataBuffer, 'utf-8'); + } + // Try to parse response to JSON, this can quietly fail + try { + // Filter out ZWNBSP character + // https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d + data = data.replace(/^\uFEFF/, ''); + if (!disableParsingResponseJson) { + data = JSON.parse(data); + } + } catch { } + + return { data, dataBuffer }; +}; + module.exports = { uuid, stringifyJson, @@ -93,5 +120,6 @@ module.exports = { safeParseJSON, simpleHash, generateUidBasedOnHash, - flattenDataForDotNotation + flattenDataForDotNotation, + parseDataFromResponse }; diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js index d912e0d4f..92f1513c8 100644 --- a/packages/bruno-electron/src/utils/oauth2.js +++ b/packages/bruno-electron/src/utils/oauth2.js @@ -163,6 +163,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo headers: response.headers, data: response.data, timestamp: Date.now(), + timeline: response?.timeline }; return response; }, (error) => { @@ -174,6 +175,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo headers: error.response.headers, data: error.response.data, timestamp: Date.now(), + timeline: error?.response?.timeline, error: 'fetching access token failed! check timeline network logs' }; } @@ -182,7 +184,8 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo status: '-', statusText: error.code, headers: error?.config?.headers, - data: safeStringifyJSON(error?.errors) + data: safeStringifyJSON(error?.errors), + timeline: error?.response?.timeline }; } return axiosResponseInfo; @@ -190,7 +193,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo const response = await axiosInstance(requestCopy); const parsedResponseData = safeParseJSON( - Buffer.isBuffer(response.data) ? response.data.toString() : response.data + Buffer.isBuffer(response.data) ? response.data?.toString() : response.data ); // Ensure debugInfo.data is initialized if (!debugInfo) { @@ -217,7 +220,8 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo dataBuffer: axiosResponseInfo?.data, status: axiosResponseInfo?.status, statusText: axiosResponseInfo?.statusText, - error: axiosResponseInfo?.error + error: axiosResponseInfo?.error, + timeline: axiosResponseInfo?.timeline }, fromCache: false, completed: true, @@ -365,13 +369,14 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo try { const axiosInstance = makeAxiosInstance(); - // Interceptor to capture request data axiosInstance.interceptors.request.use((config) => { + const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data); axiosRequestInfo = { method: config.method.toUpperCase(), url: config.url, headers: config.headers, - data: config.data, + data: requestData, + dataBuffer: Buffer.from(requestData), timestamp: Date.now(), }; return config; @@ -380,12 +385,13 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo // Interceptor to capture response data axiosInstance.interceptors.response.use((response) => { axiosResponseInfo = { - url: response.url, + url: response?.url, status: response.status, statusText: response.statusText, headers: response.headers, data: response.data, timestamp: Date.now(), + timeline: response?.timeline }; return response; }, (error) => { @@ -397,14 +403,26 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo headers: error.response.headers, data: error.response.data, timestamp: Date.now(), + timeline: error?.response?.timeline, + error: 'fetching access token failed! check timeline network logs' }; } - return Promise.reject(error); + else if(error?.code) { + axiosResponseInfo = { + status: '-', + statusText: error.code, + headers: error?.config?.headers, + data: safeStringifyJSON(error?.errors), + timeline: error?.response?.timeline + }; + } + return axiosResponseInfo; }); const response = await axiosInstance(requestCopy); - const responseData = Buffer.isBuffer(response.data) ? response.data.toString() : response.data; - const parsedResponseData = safeParseJSON(responseData); + const parsedResponseData = safeParseJSON( + Buffer.isBuffer(response.data) ? response.data.toString() : response.data + ); // Add the axios request and response info as a main request in debugInfo const axiosMainRequest = { @@ -413,16 +431,18 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo url: axiosRequestInfo?.url, method: axiosRequestInfo?.method, headers: axiosRequestInfo?.headers || {}, - body: axiosRequestInfo?.data, + data: axiosRequestInfo?.data, + dataBuffer: axiosRequestInfo?.dataBuffer, error: null }, response: { url: axiosResponseInfo.url, headers: axiosResponseInfo?.headers, data: parsedResponseData, - dataBuffer: axiosResponseInfo?.data?.toString('base64'), + dataBuffer: axiosResponseInfo?.data, status: axiosResponseInfo?.status, statusText: axiosResponseInfo?.statusText, + timeline: axiosResponseInfo?.timeline, error: null }, fromCache: false, @@ -539,13 +559,14 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, try { const axiosInstance = makeAxiosInstance(); - // Interceptor to capture request data axiosInstance.interceptors.request.use((config) => { + const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data); axiosRequestInfo = { method: config.method.toUpperCase(), url: config.url, headers: config.headers, - data: config.data, + data: requestData, + dataBuffer: Buffer.from(requestData), timestamp: Date.now(), }; return config; @@ -554,12 +575,13 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, // Interceptor to capture response data axiosInstance.interceptors.response.use((response) => { axiosResponseInfo = { - url: response.url, + url: response?.url, status: response.status, statusText: response.statusText, headers: response.headers, data: response.data, timestamp: Date.now(), + timeline: response?.timeline }; return response; }, (error) => { @@ -571,14 +593,25 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, headers: error.response.headers, data: error.response.data, timestamp: Date.now(), + timeline: error?.response?.timeline, + error: 'fetching access token failed! check timeline network logs' }; } - return Promise.reject(error); + else if(error?.code) { + axiosResponseInfo = { + status: '-', + statusText: error.code, + headers: error?.config?.headers, + data: safeStringifyJSON(error?.errors), + timeline: error?.response?.timeline + }; + } + return axiosResponseInfo; }); - const response = await axiosInstance(requestCopy); - const responseData = Buffer.isBuffer(response.data) ? response.data.toString() : response.data; - const parsedResponseData = safeParseJSON(responseData); + const parsedResponseData = safeParseJSON( + Buffer.isBuffer(response.data) ? response.data.toString() : response.data + ); // Add the axios request and response info as a main request in debugInfo const axiosMainRequest = { @@ -587,16 +620,18 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, url: axiosRequestInfo?.url, method: axiosRequestInfo?.method, headers: axiosRequestInfo?.headers || {}, - body: axiosRequestInfo?.data, + data: axiosRequestInfo?.data, + dataBuffer: axiosRequestInfo?.dataBuffer, error: null }, response: { url: axiosResponseInfo?.url, headers: axiosResponseInfo?.headers, data: parsedResponseData, - dataBuffer: axiosResponseInfo?.data?.toString('base64'), + dataBuffer: axiosResponseInfo?.data, status: axiosResponseInfo?.status, statusText: axiosResponseInfo?.statusText, + timeline: axiosResponseInfo?.timeline, error: null }, fromCache: false,