refactor: move from network strategies to generic interceptor service (#3242)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
218
packages/hoppscotch-common/src/services/interceptor.service.ts
Normal file
218
packages/hoppscotch-common/src/services/interceptor.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user