refactor: move from network strategies to generic interceptor service (#3242)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user