refactor: move from network strategies to generic interceptor service (#3242)

This commit is contained in:
Andrew Bastin
2023-08-21 07:50:35 +05:30
committed by GitHub
parent d4d1e27ba9
commit 10bb68a538
33 changed files with 1470 additions and 1314 deletions

View File

@@ -0,0 +1,106 @@
import * as E from "fp-ts/Either"
import {
Interceptor,
InterceptorError,
RequestRunResult,
} from "../../../services/interceptor.service"
import axios, { AxiosRequestConfig, CancelToken } from "axios"
import { cloneDeep } from "lodash-es"
export const preProcessRequest = (
req: AxiosRequestConfig
): AxiosRequestConfig => {
const reqClone = cloneDeep(req)
// If the parameters are URLSearchParams, inject them to URL instead
// This prevents issues of marshalling the URLSearchParams to the proxy
if (reqClone.params instanceof URLSearchParams) {
try {
const url = new URL(reqClone.url ?? "")
for (const [key, value] of reqClone.params.entries()) {
url.searchParams.append(key, value)
}
reqClone.url = url.toString()
} catch (e) {
// making this a non-empty block, so we can make the linter happy.
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
}
reqClone.params = {}
}
return reqClone
}
async function runRequest(
req: AxiosRequestConfig,
cancelToken: CancelToken
): RequestRunResult["response"] {
const timeStart = Date.now()
const processedReq = preProcessRequest(req)
try {
const res = await axios({
...processedReq,
cancelToken,
responseType: "arraybuffer",
})
const timeEnd = Date.now()
return E.right({
...res,
config: {
timeData: {
startTime: timeStart,
endTime: timeEnd,
},
},
})
} catch (e) {
const timeEnd = Date.now()
if (axios.isAxiosError(e) && e.response) {
return E.right({
...e.response,
config: {
timeData: {
startTime: timeStart,
endTime: timeEnd,
},
},
})
} else if (axios.isCancel(e)) {
return E.left("cancellation")
} else {
return E.left(<InterceptorError>{
humanMessage: {
heading: (t) => t("error.network_fail"),
description: (t) => t("helpers.network_fail"),
},
error: e,
})
}
}
}
export const browserInterceptor: Interceptor = {
interceptorID: "browser",
name: (t) => t("state.none"),
selectable: { type: "selectable" },
runRequest(req) {
const cancelToken = axios.CancelToken.source()
const processedReq = preProcessRequest(req)
const promise = runRequest(processedReq, cancelToken.token)
return {
cancel: () => cancelToken.cancel(),
response: promise,
}
},
}

View File

@@ -0,0 +1,252 @@
import * as E from "fp-ts/Either"
import { AxiosRequestConfig } from "axios"
import { Service } from "dioc"
import { getI18n } from "~/modules/i18n"
import {
Interceptor,
InterceptorError,
RequestRunResult,
} from "~/services/interceptor.service"
import { cloneDeep } from "lodash-es"
import { computed, readonly, ref } from "vue"
import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent"
import SettingsExtension from "~/components/settings/Extension.vue"
import InterceptorsExtensionSubtitle from "~/components/interceptors/ExtensionSubtitle.vue"
export const defineSubscribableObject = <T extends object>(obj: T) => {
const proxyObject = {
...obj,
_subscribers: {} as {
// eslint-disable-next-line no-unused-vars
[key in keyof T]?: ((...args: any[]) => any)[]
},
subscribe(prop: keyof T, func: (...args: any[]) => any): void {
if (Array.isArray(this._subscribers[prop])) {
this._subscribers[prop]?.push(func)
} else {
this._subscribers[prop] = [func]
}
},
}
type SubscribableProxyObject = typeof proxyObject
return new Proxy(proxyObject, {
set(obj, prop, newVal) {
obj[prop as keyof SubscribableProxyObject] = newVal
const currentSubscribers = obj._subscribers[prop as keyof T]
if (Array.isArray(currentSubscribers)) {
for (const subscriber of currentSubscribers) {
subscriber(newVal)
}
}
return true
},
})
}
// TODO: Rework this to deal with individual requests rather than cancel all
export const cancelRunningExtensionRequest = () => {
window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest()
}
const preProcessRequest = (req: AxiosRequestConfig): AxiosRequestConfig => {
const reqClone = cloneDeep(req)
// If the parameters are URLSearchParams, inject them to URL instead
// This prevents marshalling issues with structured cloning of URLSearchParams
if (reqClone.params instanceof URLSearchParams) {
try {
const url = new URL(reqClone.url ?? "")
for (const [key, value] of reqClone.params.entries()) {
url.searchParams.append(key, value)
}
reqClone.url = url.toString()
} catch (e) {
// making this a non-empty block, so we can make the linter happy.
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
}
reqClone.params = {}
}
return reqClone
}
export type ExtensionStatus = "available" | "unknown-origin" | "waiting"
/**
* This service is responsible for defining the extension interceptor.
*/
export class ExtensionInterceptorService
extends Service
implements Interceptor
{
public static readonly ID = "EXTENSION_INTERCEPTOR_SERVICE"
private _extensionStatus = ref<ExtensionStatus>("waiting")
/**
* The status of the extension, whether it's available, or not.
*/
public extensionStatus = readonly(this._extensionStatus)
/**
* The version of the extension, if available.
*/
public extensionVersion = computed(() => {
if (this.extensionStatus.value === "available") {
return window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion()
} else {
return null
}
})
/**
* Whether the extension is installed in Chrome or not.
*/
public chromeExtensionInstalled = computed(
() => this.extensionStatus.value === "available" && browserIsChrome()
)
/**
* Whether the extension is installed in Firefox or not.
*/
public firefoxExtensionInstalled = computed(
() => this.extensionStatus.value === "available" && browserIsFirefox()
)
public interceptorID = "extension"
public settingsPageEntry: Interceptor["settingsPageEntry"] = {
entryTitle: (t) => t("settings.extensions"),
component: SettingsExtension,
}
public selectorSubtitle = InterceptorsExtensionSubtitle
public selectable = { type: "selectable" as const }
constructor() {
super()
this.listenForExtensionStatus()
}
private listenForExtensionStatus() {
const extensionPollIntervalId = ref<ReturnType<typeof setInterval>>()
if (window.__HOPP_EXTENSION_STATUS_PROXY__) {
this._extensionStatus.value =
window.__HOPP_EXTENSION_STATUS_PROXY__.status
window.__HOPP_EXTENSION_STATUS_PROXY__.subscribe(
"status",
(status: ExtensionStatus) => {
this._extensionStatus.value = status
}
)
} else {
const statusProxy = defineSubscribableObject({
status: "waiting" as ExtensionStatus,
})
window.__HOPP_EXTENSION_STATUS_PROXY__ = statusProxy
statusProxy.subscribe(
"status",
(status: ExtensionStatus) => (this._extensionStatus.value = status)
)
/**
* Keeping identifying extension backward compatible
* We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately,
* then we use a poll to find the version, this will get the version for 0.24 and any other version
* of the extension, but will have a slight lag.
* 0.24 users will get the benefits of 0.24, while the extension won't break for the old users
*/
extensionPollIntervalId.value = setInterval(() => {
if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") {
if (extensionPollIntervalId.value)
clearInterval(extensionPollIntervalId.value)
const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
// When the version is not 0.24 or higher, the extension wont do this. so we have to do it manually
if (
version.major === 0 &&
version.minor <= 23 &&
window.__HOPP_EXTENSION_STATUS_PROXY__
) {
window.__HOPP_EXTENSION_STATUS_PROXY__.status = "available"
}
}
}, 2000)
}
}
public name(t: ReturnType<typeof getI18n>) {
return computed(() => {
const version = window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion()
if (this.extensionStatus.value === "available" && version) {
const { major, minor } = version
return `${t("settings.extensions")}: v${major}.${minor}`
} else {
return `${t("settings.extensions")}: ${t(
"settings.extension_ver_not_reported"
)}`
}
})
}
private async runRequestOnExtension(
req: AxiosRequestConfig
): RequestRunResult["response"] {
const extensionHook = window.__POSTWOMAN_EXTENSION_HOOK__
if (!extensionHook) {
return E.left(<InterceptorError>{
// TODO: i18n this
humanMessage: {
heading: () => "Extension not found",
description: () => "Heading not found",
},
error: "NO_PW_EXT_HOOK",
})
}
try {
const result = await extensionHook.sendRequest({
...req,
wantsBinary: true,
})
return E.right(result)
} catch (e) {
return E.left(<InterceptorError>{
// TODO: i18n this
humanMessage: {
heading: () => "Extension error",
description: () => "Failed running request on extension",
},
error: e,
})
}
}
public runRequest(
request: AxiosRequestConfig
): RequestRunResult<InterceptorError> {
const processedReq = preProcessRequest(request)
return {
cancel: cancelRunningExtensionRequest,
response: this.runRequestOnExtension(processedReq),
}
}
}

View File

@@ -0,0 +1,115 @@
import { Interceptor, RequestRunResult } from "~/services/interceptor.service"
import { AxiosRequestConfig, CancelToken } from "axios"
import * as E from "fp-ts/Either"
import { preProcessRequest } from "./browser"
import { v4 } from "uuid"
import axios from "axios"
import { settingsStore } from "~/newstore/settings"
import { decodeB64StringToArrayBuffer } from "~/helpers/utils/b64"
import SettingsProxy from "~/components/settings/Proxy.vue"
type ProxyHeaders = {
"multipart-part-key"?: string
}
type ProxyPayloadType =
| FormData
| (AxiosRequestConfig & { wantsBinary: true; accessToken: string })
const getProxyPayload = (
req: AxiosRequestConfig,
multipartKey: string | null
) => {
let payload: ProxyPayloadType = {
...req,
wantsBinary: true,
accessToken: import.meta.env.VITE_PROXYSCOTCH_ACCESS_TOKEN ?? "",
}
if (payload.data instanceof FormData) {
const formData = payload.data
payload.data = ""
formData.append(multipartKey!, JSON.stringify(payload))
payload = formData
}
return payload
}
async function runRequest(
req: AxiosRequestConfig,
cancelToken: CancelToken
): RequestRunResult["response"] {
const multipartKey =
req.data instanceof FormData ? `proxyRequestData-${v4()}` : null
const headers =
req.data instanceof FormData
? <ProxyHeaders>{
"multipart-part-key": multipartKey,
}
: <ProxyHeaders>{}
const payload = getProxyPayload(req, multipartKey)
try {
// TODO: Validation for the proxy result
const { data } = await axios.post(
settingsStore.value.PROXY_URL ?? "https://proxy.hoppscotch.io",
payload,
{
headers,
cancelToken,
}
)
if (!data.success) {
return E.left({
humanMessage: {
heading: (t) => t("error.network_fail"),
description: (t) => data.data?.message ?? t("error.proxy_error"),
},
})
}
if (data.isBinary) {
data.data = decodeB64StringToArrayBuffer(data.data)
}
return E.right(data)
} catch (e) {
if (axios.isCancel(e)) {
return E.left("cancellation")
} else {
return E.left({
humanMessage: {
heading: (t) => t("error.network_fail"),
description: (t) => t("helpers.network_fail"),
},
error: e,
})
}
}
}
export const proxyInterceptor: Interceptor = {
interceptorID: "proxy",
name: (t) => t("settings.proxy"),
selectable: { type: "selectable" },
settingsPageEntry: {
entryTitle: (t) => t("settings.proxy"),
component: SettingsProxy,
},
runRequest(req) {
const cancelToken = axios.CancelToken.source()
const processedReq = preProcessRequest(req)
const promise = runRequest(processedReq, cancelToken.token)
return {
cancel: () => cancelToken.cancel(),
response: promise,
}
},
}