diff --git a/packages/hoppscotch-app/components/http/ResponseMeta.vue b/packages/hoppscotch-app/components/http/ResponseMeta.vue index c32e9683c..f56947620 100644 --- a/packages/hoppscotch-app/components/http/ResponseMeta.vue +++ b/packages/hoppscotch-app/components/http/ResponseMeta.vue @@ -138,7 +138,8 @@ const props = defineProps<{ const statusCategory = computed(() => { if ( props.response.type === "loading" || - props.response.type === "network_fail" + props.response.type === "network_fail" || + props.response.type === "script_fail" ) return "" return findStatusGroup(props.response.statusCode) diff --git a/packages/hoppscotch-app/helpers/network.ts b/packages/hoppscotch-app/helpers/network.ts index 59f84d932..7fe08c915 100644 --- a/packages/hoppscotch-app/helpers/network.ts +++ b/packages/hoppscotch-app/helpers/network.ts @@ -1,6 +1,9 @@ -import { AxiosRequestConfig } from "axios" +import { AxiosResponse, AxiosRequestConfig } from "axios" import { BehaviorSubject, Observable } from "rxjs" import cloneDeep from "lodash/cloneDeep" +import * as T from "fp-ts/Task" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" import AxiosStrategy, { cancelRunningAxiosRequest, } from "./strategies/AxiosStrategy" @@ -12,6 +15,19 @@ import { HoppRESTResponse } from "./types/HoppRESTResponse" import { EffectiveHoppRESTRequest } from "./utils/EffectiveURL" import { settingsStore } from "~/newstore/settings" +export type NetworkResponse = AxiosResponse & { + config?: { + timeData?: { + startTime: number + endTime: number + } + } +} + +export type NetworkStrategy = ( + req: AxiosRequestConfig +) => TE.TaskEither + export const cancelRunningRequest = () => { if (isExtensionsAllowed() && hasExtensionInstalled()) { cancelRunningExtensionRequest() @@ -34,110 +50,161 @@ const runAppropriateStrategy = (req: AxiosRequestConfig) => { * Returns an identifier for how a request will be ran * if the system is asked to fire a request * - * @returns {"normal" | "extension" | "proxy"} */ export function getCurrentStrategyID() { if (isExtensionsAllowed() && hasExtensionInstalled()) { - return "extension" + return "extension" as const } else if (settingsStore.value.PROXY_ENABLED) { - return "proxy" + return "proxy" as const } else { - return "normal" + return "normal" as const } } export const sendNetworkRequest = (req: any) => - runAppropriateStrategy(req).finally(() => window.$nuxt.$loading.finish()) + pipe( + runAppropriateStrategy(req), + TE.getOrElse((e) => { + throw e + }) + )() + +const processResponse = ( + res: NetworkResponse, + req: EffectiveHoppRESTRequest, + backupTimeStart: number, + backupTimeEnd: number, + successState: HoppRESTResponse["type"] +) => + pipe( + TE.Do, + + // Calculate the content length + TE.bind("contentLength", () => + TE.of( + res.headers["content-length"] + ? parseInt(res.headers["content-length"]) + : (res.data as ArrayBuffer).byteLength + ) + ), + + // Building the final response object + TE.map( + ({ contentLength }) => + { + type: successState, + statusCode: res.status, + body: res.data, + headers: Object.keys(res.headers).map((x) => ({ + key: x, + value: res.headers[x], + })), + meta: { + responseSize: contentLength, + responseDuration: backupTimeEnd - backupTimeStart, + }, + req, + } + ) + ) export function createRESTNetworkRequestStream( request: EffectiveHoppRESTRequest ): Observable { - const req = cloneDeep(request) const response = new BehaviorSubject({ type: "loading", - req, + req: request, }) - const headers = req.effectiveFinalHeaders.reduce((acc, { key, value }) => { - return Object.assign(acc, { [key]: value }) - }, {}) + pipe( + TE.Do, - const params = req.effectiveFinalParams.reduce((acc, { key, value }) => { - return Object.assign(acc, { [key]: value }) - }, {}) + // Get a deep clone of the request + TE.bind("req", () => { + debugger + return TE.of(cloneDeep(request)) + }), - const timeStart = Date.now() + // Assembling headers object + TE.bind("headers", ({ req }) => { + debugger + return TE.of( + req.effectiveFinalHeaders.reduce((acc, { key, value }) => { + return Object.assign(acc, { [key]: value }) + }, {}) + ) + }), - runAppropriateStrategy({ - method: req.method as any, - url: req.effectiveFinalURL, - headers, - params, - data: req.effectiveFinalBody, - }) - .then((res: any) => { - const timeEnd = Date.now() + // Assembling params object + TE.bind("params", ({ req }) => { + debugger + return TE.of( + req.effectiveFinalParams.reduce((acc, { key, value }) => { + return Object.assign(acc, { [key]: value }) + }, {}) + ) + }), - const contentLength = res.headers["content-length"] - ? parseInt(res.headers["content-length"]) - : (res.data as ArrayBuffer).byteLength + // Keeping the backup start time + TE.bind("backupTimeStart", () => { + debugger + return TE.of(Date.now()) + }), - const resObj: HoppRESTResponse = { - type: "success", - statusCode: res.status, - body: res.data, - headers: Object.keys(res.headers).map((x) => ({ - key: x, - value: res.headers[x], - })), - meta: { - responseSize: contentLength, - responseDuration: timeEnd - timeStart, - }, + // Running the request and getting the response + TE.bind("res", ({ req, headers, params }) => { + debugger + return runAppropriateStrategy({ + method: req.method as any, + url: req.effectiveFinalURL, + headers, + params, + data: req.effectiveFinalBody, + }) + }), + + // Getting the backup end time + TE.bind("backupTimeEnd", () => { + debugger + return TE.of(Date.now()) + }), + + // Assemble the final response object + TE.chainW(({ req, res, backupTimeEnd, backupTimeStart }) => { + debugger + return processResponse( + res, req, - } - response.next(resObj) + backupTimeStart, + backupTimeEnd, + "success" + ) + }), + // Writing success state to the stream + TE.chain((res) => { + debugger + response.next(res) response.complete() - }) - .catch((e) => { - if (e.response) { - const timeEnd = Date.now() - const contentLength = e.response.headers["content-length"] - ? parseInt(e.response.headers["content-length"]) - : (e.response.data as ArrayBuffer).byteLength + return TE.of(res) + }), - const resObj: HoppRESTResponse = { - type: "fail", - body: e.response.data, - headers: Object.keys(e.response.headers).map((x) => ({ - key: x, - value: e.response.headers[x], - })), - meta: { - responseDuration: timeEnd - timeStart, - responseSize: contentLength, - }, - req, - statusCode: e.response.status, - } - - response.next(resObj) - - response.complete() - } else { - const resObj: HoppRESTResponse = { - type: "network_fail", - error: e, - req, - } - - response.next(resObj) - - response.complete() + // Package the error type + TE.getOrElseW((e) => { + debugger + const obj: HoppRESTResponse = { + type: "network_fail", + error: e, + req: request, } + + response.next(obj) + response.complete() + + return T.of(obj) }) + )() return response } diff --git a/packages/hoppscotch-app/helpers/strategies/AxiosStrategy.js b/packages/hoppscotch-app/helpers/strategies/AxiosStrategy.js deleted file mode 100644 index defd01d3b..000000000 --- a/packages/hoppscotch-app/helpers/strategies/AxiosStrategy.js +++ /dev/null @@ -1,94 +0,0 @@ -import axios from "axios" -import { v4 } from "uuid" -import { decodeB64StringToArrayBuffer } from "../utils/b64" -import { settingsStore } from "~/newstore/settings" -import { JsonFormattedError } from "~/helpers/utils/JsonFormattedError" - -let cancelSource = axios.CancelToken.source() - -export const cancelRunningAxiosRequest = () => { - cancelSource.cancel() - - // Create a new cancel token - cancelSource = axios.CancelToken.source() -} - -const axiosWithProxy = async (req) => { - try { - let proxyReqPayload = { - ...req, - wantsBinary: true, - } - const headers = {} - if (req.data instanceof FormData) { - const key = `proxyRequestData-${v4()}` // generate UniqueKey - headers["multipart-part-key"] = key - - const formData = proxyReqPayload.data - proxyReqPayload.data = "" // discard formData - formData.append(key, JSON.stringify(proxyReqPayload)) // append axiosRequest to form - proxyReqPayload = formData - } - const { data } = await axios.post( - settingsStore.value.PROXY_URL || "https://proxy.hoppscotch.io", - proxyReqPayload, - { - headers, - cancelToken: cancelSource.token, - } - ) - - if (!data.success) { - throw new Error(data.data.message || "Proxy Error") - } - - if (data.isBinary) { - data.data = decodeB64StringToArrayBuffer(data.data) - } - - return data - } catch (e) { - // Check if the throw is due to a cancellation - if (axios.isCancel(e)) { - // eslint-disable-next-line no-throw-literal - throw "cancellation" - } else { - throw e - } - } -} - -const axiosWithoutProxy = async (req, _store) => { - try { - const res = await axios({ - ...req, - cancelToken: (cancelSource && cancelSource.token) || "", - responseType: "arraybuffer", - }) - return res - } catch (e) { - if (axios.isCancel(e)) { - // eslint-disable-next-line no-throw-literal - throw "cancellation" - } else if (e.response?.data) { - throw new JsonFormattedError( - JSON.parse(Buffer.from(e.response.data, "base64").toString("utf8")) - ) - } else { - throw e - } - } -} - -const axiosStrategy = (req) => { - if (settingsStore.value.PROXY_ENABLED) { - return axiosWithProxy(req) - } - return axiosWithoutProxy(req) -} - -export const testables = { - cancelSource, -} - -export default axiosStrategy diff --git a/packages/hoppscotch-app/helpers/strategies/AxiosStrategy.ts b/packages/hoppscotch-app/helpers/strategies/AxiosStrategy.ts new file mode 100644 index 000000000..82e62129e --- /dev/null +++ b/packages/hoppscotch-app/helpers/strategies/AxiosStrategy.ts @@ -0,0 +1,129 @@ +import axios, { AxiosRequestConfig } from "axios" +import { v4 } from "uuid" +import { pipe } from "fp-ts/function" +import * as TE from "fp-ts/TaskEither" +import { NetworkResponse, NetworkStrategy } from "../network" +import { decodeB64StringToArrayBuffer } from "../utils/b64" +import { settingsStore } from "~/newstore/settings" + +let cancelSource = axios.CancelToken.source() + +type ProxyHeaders = { + "multipart-part-key"?: string +} + +type ProxyPayloadType = FormData | (AxiosRequestConfig & { wantsBinary: true }) + +export const cancelRunningAxiosRequest = () => { + cancelSource.cancel() + + // Create a new cancel token + cancelSource = axios.CancelToken.source() +} + +const getProxyPayload = ( + req: AxiosRequestConfig, + multipartKey: string | null +) => { + let payload: ProxyPayloadType = { + ...req, + wantsBinary: true, + } + + if (payload.data instanceof FormData) { + const formData = payload.data + payload.data = "" + formData.append(multipartKey!, JSON.stringify(payload)) + payload = formData + } + + return payload +} + +const axiosWithProxy: NetworkStrategy = (req) => + pipe( + TE.Do, + + // If the request has FormData, the proxy needs a key + TE.bind("multipartKey", () => + TE.of(req.data instanceof FormData ? v4() : null) + ), + + // Build headers to send + TE.bind("headers", ({ multipartKey }) => + TE.of( + req.data instanceof FormData + ? { + "multipart-part-key": `proxyRequestData-${multipartKey}`, + } + : {} + ) + ), + + // Create payload + TE.bind("payload", ({ multipartKey }) => + TE.of(getProxyPayload(req, multipartKey)) + ), + + // Run the proxy request + TE.chain(({ payload, headers }) => + TE.tryCatch( + () => + axios.post( + settingsStore.value.PROXY_URL || "https://proxy.hoppscotch.io", + payload, + { + headers, + cancelToken: cancelSource.token, + } + ), + (reason) => + axios.isCancel(reason) + ? "cancellation" // Convert cancellation errors into cancellation strings + : reason + ) + ), + + // Check success predicate + TE.chain( + TE.fromPredicate( + ({ data }) => data.success, + ({ data }) => data.data.message || "Proxy Error" + ) + ), + + // Process Base64 + TE.chain(({ data }) => { + if (data.isBinary) { + data.data = decodeB64StringToArrayBuffer(data.data) + } + + return TE.of(data) + }) + ) + +const axiosWithoutProxy: NetworkStrategy = (req) => + pipe( + TE.tryCatch( + () => + axios({ + ...req, + cancelToken: (cancelSource && cancelSource.token) || "", + responseType: "arraybuffer", + }), + (e) => (axios.isCancel(e) ? "cancellation" : (e as any)) + ), + + TE.orElse((e) => + e !== "cancellation" && e.response + ? TE.right(e.response as NetworkResponse) + : TE.left(e) + ) + ) + +const axiosStrategy: NetworkStrategy = (req) => + settingsStore.value.PROXY_ENABLED + ? axiosWithProxy(req) + : axiosWithoutProxy(req) + +export default axiosStrategy diff --git a/packages/hoppscotch-app/helpers/strategies/ExtensionStrategy.js b/packages/hoppscotch-app/helpers/strategies/ExtensionStrategy.js deleted file mode 100644 index fb6800d1c..000000000 --- a/packages/hoppscotch-app/helpers/strategies/ExtensionStrategy.js +++ /dev/null @@ -1,90 +0,0 @@ -import { decodeB64StringToArrayBuffer } from "../utils/b64" -import { settingsStore } from "~/newstore/settings" - -export const hasExtensionInstalled = () => - typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined" - -export const hasChromeExtensionInstalled = () => - hasExtensionInstalled() && - /Chrome/i.test(navigator.userAgent) && - /Google/i.test(navigator.vendor) - -export const hasFirefoxExtensionInstalled = () => - hasExtensionInstalled() && /Firefox/i.test(navigator.userAgent) - -export const cancelRunningExtensionRequest = () => { - if ( - hasExtensionInstalled() && - window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest - ) { - window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest() - } -} - -const extensionWithProxy = async (req) => { - const backupTimeDataStart = new Date().getTime() - - const res = await window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({ - method: "post", - url: settingsStore.value.PROXY_URL || "https://proxy.hoppscotch.io/", - data: { - ...req, - wantsBinary: true, - }, - }) - - const backupTimeDataEnd = new Date().getTime() - - const parsedData = JSON.parse(res.data) - - if (!parsedData.success) { - throw new Error(parsedData.data.message || "Proxy Error") - } - - if (parsedData.isBinary) { - parsedData.data = decodeB64StringToArrayBuffer(parsedData.data) - } - - if (!(res && res.config && res.config.timeData)) { - res.config = { - timeData: { - startTime: backupTimeDataStart, - endTime: backupTimeDataEnd, - }, - } - } - - parsedData.config = res.config - - return parsedData -} - -const extensionWithoutProxy = async (req) => { - const backupTimeDataStart = new Date().getTime() - - const res = await window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({ - ...req, - wantsBinary: true, - }) - - const backupTimeDataEnd = new Date().getTime() - - if (!(res && res.config && res.config.timeData)) { - res.config = { - timeData: { - startTime: backupTimeDataStart, - endTime: backupTimeDataEnd, - }, - } - } - return res -} - -const extensionStrategy = (req) => { - if (settingsStore.value.PROXY_ENABLED) { - return extensionWithProxy(req) - } - return extensionWithoutProxy(req) -} - -export default extensionStrategy diff --git a/packages/hoppscotch-app/helpers/strategies/ExtensionStrategy.ts b/packages/hoppscotch-app/helpers/strategies/ExtensionStrategy.ts new file mode 100644 index 000000000..8c18bec48 --- /dev/null +++ b/packages/hoppscotch-app/helpers/strategies/ExtensionStrategy.ts @@ -0,0 +1,62 @@ +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import { NetworkResponse, NetworkStrategy } from "../network" + +export const hasExtensionInstalled = () => + typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined" + +export const hasChromeExtensionInstalled = () => + hasExtensionInstalled() && + /Chrome/i.test(navigator.userAgent) && + /Google/i.test(navigator.vendor) + +export const hasFirefoxExtensionInstalled = () => + hasExtensionInstalled() && /Firefox/i.test(navigator.userAgent) + +export const cancelRunningExtensionRequest = () => { + if ( + hasExtensionInstalled() && + window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest + ) { + window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest() + } +} + +const extensionStrategy: NetworkStrategy = (req) => + pipe( + TE.Do, + + // Storeing backup timing data in case the extension does not have that info + TE.bind("backupTimeDataStart", () => TE.of(new Date().getTime())), + + // Run the request + TE.bind("response", () => + TE.tryCatch( + () => + window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({ + ...req, + wantsBinary: true, + }) as Promise, + (err) => err as any + ) + ), + + // Inject backup time data if not present + TE.map(({ backupTimeDataStart, response }) => ({ + ...response, + config: { + timeData: { + startTime: backupTimeDataStart, + endTime: new Date().getTime(), + }, + ...response.config, + }, + })), + TE.orElse((e) => + e !== "cancellation" && e.response + ? TE.right(e.response as NetworkResponse) + : TE.left(e) + ) + ) + +export default extensionStrategy diff --git a/packages/hoppscotch-app/package.json b/packages/hoppscotch-app/package.json index 48d08086c..d87f46cf0 100644 --- a/packages/hoppscotch-app/package.json +++ b/packages/hoppscotch-app/package.json @@ -130,6 +130,7 @@ "@types/esprima": "^4.0.3", "@types/lodash": "^4.14.177", "@types/splitpanes": "^2.2.1", + "@types/uuid": "^8.3.3", "@urql/devtools": "^2.0.3", "@vue/runtime-dom": "^3.2.23", "@vue/test-utils": "^1.3.0", diff --git a/packages/hoppscotch-app/types/pw-ext-hook.d.ts b/packages/hoppscotch-app/types/pw-ext-hook.d.ts index 3e54377a3..bdc172c38 100644 --- a/packages/hoppscotch-app/types/pw-ext-hook.d.ts +++ b/packages/hoppscotch-app/types/pw-ext-hook.d.ts @@ -1,21 +1,10 @@ -interface PWExtensionRequestInfo { - method: string - url: string - data: any & { wantsBinary: boolean } -} +import { AxiosRequestConfig } from "axios" +import { NetworkResponse } from "~/helpers/network" -interface PWExtensionResponse { - data: any - config?: { - timeData?: { - startTime: number - endTime: number - } - } -} - -interface PWExtensionHook { +export interface PWExtensionHook { getVersion: () => { major: number; minor: number } - sendRequest: (req: PWExtensionRequestInfo) => Promise + sendRequest: ( + req: AxiosRequestConfig & { wantsBinary: boolean } + ) => Promise cancelRunningRequest: () => void } diff --git a/packages/hoppscotch-app/types/window.d.ts b/packages/hoppscotch-app/types/window.d.ts index 424c7bf33..81a6a9d02 100644 --- a/packages/hoppscotch-app/types/window.d.ts +++ b/packages/hoppscotch-app/types/window.d.ts @@ -1,3 +1,5 @@ +import { PWExtensionHook } from "./pw-ext-hook" + export {} declare global { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c56bdf62..46bc8d749 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,7 @@ importers: '@types/esprima': ^4.0.3 '@types/lodash': ^4.14.177 '@types/splitpanes': ^2.2.1 + '@types/uuid': ^8.3.3 '@urql/core': ^2.3.5 '@urql/devtools': ^2.0.3 '@urql/exchange-auth': ^0.1.6 @@ -258,6 +259,7 @@ importers: '@types/esprima': 4.0.3 '@types/lodash': 4.14.177 '@types/splitpanes': 2.2.1 + '@types/uuid': 8.3.3 '@urql/devtools': 2.0.3_@urql+core@2.3.5+graphql@15.7.2 '@vue/runtime-dom': 3.2.23 '@vue/test-utils': 1.3.0 @@ -3506,11 +3508,11 @@ packages: ufo: 0.7.9 dev: false - /@nuxt/kit-edge/3.0.0-27307420.6a25d3e: - resolution: {integrity: sha512-JieTRigkV52VEQy+oqa6OqR/qOuL9ZmoaH9fDHNwHJXN7hLmil4HbRQ9502G7ura7hkHeAhjZTthXdQDKx1Q5Q==} + /@nuxt/kit-edge/3.0.0-27313139.1c88580: + resolution: {integrity: sha512-QQwmTaF5YkP1dZZ38DC7A3tCH2SQUlzsXN3QgNqys+WzZnZB0dPuyvSqgD6L/Ts7MwIFrooOEIRz0i9iwnJPMQ==} engines: {node: ^14.16.0 || ^16.11.0 || ^17.0.0} dependencies: - '@nuxt/schema': /@nuxt/schema-edge/3.0.0-27307420.6a25d3e + '@nuxt/schema': /@nuxt/schema-edge/3.0.0-27313139.1c88580 consola: 2.15.3 defu: 5.0.0 dotenv: 10.0.0 @@ -3531,7 +3533,7 @@ packages: /@nuxt/kit/0.8.1-edge: resolution: {integrity: sha512-7kU+mYxRy3w9UohFK/rfrPkKXM9A4LWsTqpFN3MH7mxohy98SFBkf87B6nqE6ulXmztInK+MptS0Lr+VQa0E6w==} dependencies: - '@nuxt/kit-edge': 3.0.0-27307420.6a25d3e + '@nuxt/kit-edge': 3.0.0-27313139.1c88580 dev: true /@nuxt/loading-screen/2.0.4: @@ -3554,8 +3556,8 @@ packages: node-fetch: 2.6.6 dev: false - /@nuxt/schema-edge/3.0.0-27307420.6a25d3e: - resolution: {integrity: sha512-QB6zMvxMQ+H5kwqd/6vZO7UAxGLIMZGV5zEc9rlYIyoilNnMO3opBJWuaUaokDLW7JpA1bGOfakLWWg8e8LGgQ==} + /@nuxt/schema-edge/3.0.0-27313139.1c88580: + resolution: {integrity: sha512-RW4jvMAQK/PK2UdAskRaEnLetafm2n/0g82QymZJi8MsbdpgNbo/6hWx6cGDbljueUhMc/EBrTpYeGSC4FMC1A==} engines: {node: ^14.16.0 || ^16.11.0 || ^17.0.0} dependencies: create-require: 1.1.1 @@ -4551,6 +4553,10 @@ packages: dependencies: source-map: 0.6.1 + /@types/uuid/8.3.3: + resolution: {integrity: sha512-0LbEEx1zxrYB3pgpd1M5lEhLcXjKJnYghvhTRgaBeUivLHMDM1TzF3IJ6hXU2+8uA4Xz+5BA63mtZo5DjVT8iA==} + dev: true + /@types/webpack-bundle-analyzer/3.9.3: resolution: {integrity: sha512-l/vaDMWGcXiMB3CbczpyICivLTB07/JNtn1xebsRXE9tPaUDEHgX3x7YP6jfznG5TOu7I4w0Qx1tZz61znmPmg==} dependencies: