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,143 @@
import { describe, expect, it, vi } from "vitest"
import { Interceptor, InterceptorService } from "../interceptor.service"
import { TestContainer } from "dioc/testing"
describe("InterceptorService", () => {
it("initally there are no interceptors defined", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
expect(service.availableInterceptors.value).toEqual([])
})
it("currentInterceptorID should be null if no interceptors are defined", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
expect(service.currentInterceptorID.value).toBeNull()
})
it("currentInterceptorID should be set if there is an interceptor defined", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
service.registerInterceptor({
interceptorID: "test",
name: () => "Test Interceptor",
selectable: { type: "selectable" },
runRequest: () => {
throw new Error("Not implemented")
},
})
expect(service.currentInterceptorID.value).toEqual("test")
})
it("currentInterceptorID cannot be set to null if there are interceptors defined", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
service.registerInterceptor({
interceptorID: "test",
name: () => "Test Interceptor",
selectable: { type: "selectable" },
runRequest: () => {
throw new Error("Not implemented")
},
})
service.currentInterceptorID.value = null
expect(service.currentInterceptorID.value).not.toBeNull()
})
it("currentInterceptorID cannot be set to an unknown interceptor ID", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
service.registerInterceptor({
interceptorID: "test",
name: () => "Test Interceptor",
selectable: { type: "selectable" },
runRequest: () => {
throw new Error("Not implemented")
},
})
service.currentInterceptorID.value = "unknown"
expect(service.currentInterceptorID.value).not.toEqual("unknown")
})
describe("registerInterceptor", () => {
it("should register the interceptor", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
const interceptor: Interceptor = {
interceptorID: "test",
name: () => "Test Interceptor",
selectable: { type: "selectable" },
runRequest: () => {
throw new Error("Not implemented")
},
}
service.registerInterceptor(interceptor)
expect(service.availableInterceptors.value).toEqual([interceptor])
})
it("should set the current interceptor ID to non-null after the intiial registration", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
const interceptor: Interceptor = {
interceptorID: "test",
name: () => "Test Interceptor",
selectable: { type: "selectable" },
runRequest: () => {
throw new Error("Not implemented")
},
}
service.registerInterceptor(interceptor)
expect(service.currentInterceptorID.value).not.toBeNull()
})
})
describe("runRequest", () => {
it("should throw an error if no interceptor is selected", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
expect(() => service.runRequest({})).toThrowError()
})
it("asks the current interceptor to run the request", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
const interceptor: Interceptor = {
interceptorID: "test",
name: () => "Test Interceptor",
selectable: { type: "selectable" },
runRequest: vi.fn(),
}
service.registerInterceptor(interceptor)
service.runRequest({})
expect(interceptor.runRequest).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,218 @@
import * as E from "fp-ts/Either"
import { Service } from "dioc"
import { MaybeRef, refWithControl } from "@vueuse/core"
import { AxiosRequestConfig, AxiosResponse } from "axios"
import type { getI18n } from "~/modules/i18n"
import { throwError } from "~/helpers/functional/error"
import { Component, Ref, computed, reactive, watch, unref, markRaw } from "vue"
/**
* Defines the response data from an interceptor request run.
*/
export type NetworkResponse = AxiosResponse<unknown> & {
config?: {
timeData?: {
startTime: number
endTime: number
}
}
}
/**
* Defines the errors that can occur during interceptor request run.
*/
export type InterceptorError =
| "cancellation"
| {
humanMessage: {
heading: (t: ReturnType<typeof getI18n>) => string
description: (t: ReturnType<typeof getI18n>) => string
}
error?: unknown
}
/**
* Defines the result of an interceptor request run.
*/
export type RequestRunResult<Err extends InterceptorError = InterceptorError> =
{
/**
* Cancels the interceptor request run.
*/
cancel: () => void
/**
* Promise that resolves when the interceptor request run is finished.
*/
response: Promise<E.Either<Err, NetworkResponse>>
}
/**
* Defines whether an interceptor is selectable or not
*/
export type InterceptorSelectableStatus<CustomComponentProps = any> =
| { type: "selectable" }
| {
type: "unselectable"
reason:
| {
type: "text"
text: (t: ReturnType<typeof getI18n>) => string
action?: {
text: (t: ReturnType<typeof getI18n>) => string
onActionClick: () => void
}
}
| {
type: "custom"
component: Component<CustomComponentProps>
props: CustomComponentProps
}
}
/**
* An interceptor is an object that defines how to run a Hoppscotch request.
*/
export type Interceptor<Err extends InterceptorError = InterceptorError> = {
/**
* The ID of the interceptor. This should be unique across all registered interceptors.
*/
interceptorID: string
/**
* The function that returns the name of the interceptor.
* @param t The i18n function.
*/
name: (t: ReturnType<typeof getI18n>) => MaybeRef<string>
/**
* Defines what to render in the Interceptor section of the Settings page.
* Use this space to define interceptor specific settings.
* Not setting this will lead to nothing being rendered about this interceptor in the settings page.
*/
settingsPageEntry?: {
/**
* The title of the interceptor entry in the settings page.
*/
entryTitle: (t: ReturnType<typeof getI18n>) => string
/**
* The component to render in the settings page.
*/
component: Component
}
/**
* Defines what to render under the entry for the interceptor in the Interceptor selector.
*/
selectorSubtitle?: Component
/**
* Defines whether the interceptor is selectable or not.
*/
selectable: MaybeRef<InterceptorSelectableStatus<unknown>>
/**
* Runs the interceptor on the given request.
* NOTE: Make sure this function doesn't throw, instead when an error occurs, return a Left Either with the error.
* @param request The request to run the interceptor on.
*/
runRequest: (request: AxiosRequestConfig) => RequestRunResult<Err>
}
/**
* This service deals with the registration and execution of
* interceptors for request execution.
*/
export class InterceptorService extends Service {
public static readonly ID = "INTERCEPTOR_SERVICE"
private interceptors: Map<string, Interceptor> = reactive(new Map())
/**
* The ID of the currently selected interceptor.
* If `null`, there are no interceptors registered or none can be selected.
*/
public currentInterceptorID: Ref<string | null> = refWithControl(
null as string | null,
{
onBeforeChange: (value) => {
if (!value) {
// Only allow `null` if there are no interceptors
return this.availableInterceptors.value.length === 0
}
if (value && !this.interceptors.has(value)) {
console.warn(
"Attempt to set current interceptor ID to unknown ID is ignored"
)
return false
}
return true
},
}
)
/**
* List of interceptors that are registered with the service.
*/
public availableInterceptors = computed(() =>
Array.from(this.interceptors.values())
)
constructor() {
super()
// If the current interceptor is unselectable, select the first selectable one, else null
watch([() => this.interceptors, this.currentInterceptorID], () => {
if (!this.currentInterceptorID.value) return
const interceptor = this.interceptors.get(this.currentInterceptorID.value)
if (!interceptor) {
this.currentInterceptorID.value = null
return
}
if (unref(interceptor.selectable).type === "unselectable") {
this.currentInterceptorID.value =
this.availableInterceptors.value.filter(
(interceptor) => unref(interceptor.selectable).type === "selectable"
)[0]?.interceptorID ?? null
}
})
}
/**
* Register an interceptor with the service.
* @param interceptor The interceptor to register
*/
public registerInterceptor(interceptor: Interceptor) {
// markRaw so that interceptor state by itself is not fully marked reactive
this.interceptors.set(interceptor.interceptorID, markRaw(interceptor))
if (this.currentInterceptorID.value === null) {
this.currentInterceptorID.value = interceptor.interceptorID
}
}
/**
* Runs a request through the currently selected interceptor.
* @param req The request to run
* @throws If no interceptor is selected
*/
public runRequest(req: AxiosRequestConfig): RequestRunResult {
if (!this.currentInterceptorID.value) {
throw new Error("No interceptor selected")
}
const interceptor =
this.interceptors.get(this.currentInterceptorID.value) ??
throwError(
"Current Interceptor ID is not found in the list of registered interceptors"
)
return interceptor.runRequest(req)
}
}