diff --git a/README.md b/README.md index 82cd417..8b34ebd 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,64 @@ try { } ``` +## Interceptors + +You can intercept requests or responses before they are handled by `then` or `catch`. + +```js +// Add a request and response interceptor +await get("http://localhost:5000/todos/1", { + interceptors: { + request: async (err, request) => { + // Do something before request is sent + return request; + }, + response: async (err, response) => { + // Do something with response data + return response; + } + }, +}); +``` + +You can add interceptors to a custom instance of nordus. + +```js +const instance = nordus.create(); +instance.interceptors.request.use(() => {/*...*/}); +``` + +If you need to remove an interceptor later you can. + +```js +const myInterceptor = instance.interceptors.request.use(() => {/*...*/}); +instance.interceptors.request.eject(myInterceptor); +``` + +You can also clear all interceptors for requests or responses. +```js +const instance = nordus.create(); +instance.interceptors.request.use(() => {/*...*/}); +instance.interceptors.request.clear(); // Removes interceptors from requests +instance.interceptors.response.use(() => {/*...*/}); +instance.interceptors.response.clear(); // Removes interceptors from responses +``` + +### Multiple Interceptors + +Given you add multiple response interceptors +and when the response was fulfilled +- then each interceptor is executed +- then they are executed in the order they were added +- then only the last interceptor's result is returned +- then every interceptor receives the result of its predecessor +- and when the fulfillment-interceptor throws + - then the following fulfillment-interceptor is not called + - then the following rejection-interceptor is called + - once caught, another following fulfill-interceptor is called again (just like in a promise chain). + +Read [the interceptor tests](./tests/interceptors.spec.ts) for seeing all this in code. + ## License [MIT](LICENSE) diff --git a/src/index.ts b/src/index.ts index 292ca0e..957ea17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,16 @@ +import { + NordusInterceptorManager, + createInterceptors, + requestInterptorsManager, + responseInterceptorsManager, +} from "./interceptors"; import { InterceptorRequest, InterceptorResponse, NordusConfig, NordusConfigApi, NordusRequest, + NordusResponse, } from "./request"; import { append } from "./utils"; @@ -89,28 +96,63 @@ function mergeConfig( }; } -export function create(nordusConf?: NordusConfig) { +interface Nordus { + interceptors: { + request: NordusInterceptorManager; + response: NordusInterceptorManager; + }; + get: ( + url: string, + nordusConfig?: NordusConfig, + ) => Promise>; + post: ( + url: string, + body: D, + nordusConfig?: NordusConfig, + ) => Promise>; + put: ( + url: string, + body: D, + nordusConfig?: NordusConfig, + ) => Promise>; + patch: ( + url: string, + body: D, + nordusConfig?: NordusConfig, + ) => Promise>; + del: ( + url: string, + nordusConfig?: NordusConfig, + ) => Promise>; +} + +export function create(nordusConfig?: NordusConfig) { + const nordusConfigApi = getDefaultConfig(nordusConfig); return { get: (url: string, nordusConfig?: NordusConfig) => - get(url, mergeConfig(nordusConf, nordusConfig)), + get(url, mergeConfig(nordusConfigApi, nordusConfig)), post: ( url: string, body: D, nordusConfig?: NordusConfig, - ) => post(url, body, mergeConfig(nordusConf, nordusConfig)), + ) => post(url, body, mergeConfig(nordusConfigApi, nordusConfig)), put: ( url: string, body: D, nordusConfig?: NordusConfig, - ) => put(url, body, mergeConfig(nordusConf, nordusConfig)), + ) => put(url, body, mergeConfig(nordusConfigApi, nordusConfig)), patch: ( url: string, body: D, nordusConfig?: NordusConfig, - ) => patch(url, body, mergeConfig(nordusConf, nordusConfig)), + ) => patch(url, body, mergeConfig(nordusConfigApi, nordusConfig)), del: (url: string, nordusConfig?: NordusConfig) => - del(url, mergeConfig(nordusConf, nordusConfig)), - }; + del(url, mergeConfig(nordusConfigApi, nordusConfig)), + interceptors: { + request: requestInterptorsManager(nordusConfigApi), + response: responseInterceptorsManager(nordusConfigApi), + }, + } as Nordus; } function getDefaultConfig(nordusConfig?: NordusConfig) { @@ -118,5 +160,7 @@ function getDefaultConfig(nordusConfig?: NordusConfig) { if (!nordusConfig.responseType) nordusConfig.responseType = "json"; + createInterceptors(nordusConfig); + return nordusConfig as NordusConfigApi; } diff --git a/src/interceptors.ts b/src/interceptors.ts new file mode 100644 index 0000000..0efc144 --- /dev/null +++ b/src/interceptors.ts @@ -0,0 +1,76 @@ +import { + InterceptorRequest, + InterceptorResponse, + NordusConfig, + NordusConfigApi, +} from "./request"; +import { asArray, randomUUID, getValueByKey } from "./utils"; + +export interface NordusInterceptorManager { + use(interceptor: V): string; + eject(id: string): void; + clear(): void; +} + +export function requestInterptorsManager( + nordusConfigApi: NordusConfigApi, +): NordusInterceptorManager { + return { + use: (interceptor: InterceptorRequest) => { + return addInterceptor(nordusConfigApi.interceptors.request, interceptor); + }, + eject: (id: string) => { + nordusConfigApi.interceptors.request = removeInterceptor( + nordusConfigApi.interceptors.request, + id, + ); + }, + clear: () => { + nordusConfigApi.interceptors.request = []; + }, + }; +} + +export function responseInterceptorsManager( + nordusConfigApi: NordusConfigApi, +): NordusInterceptorManager { + return { + use: (interceptor: InterceptorResponse) => { + return addInterceptor(nordusConfigApi.interceptors.response, interceptor); + }, + eject: (id: string) => { + nordusConfigApi.interceptors.response = removeInterceptor( + nordusConfigApi.interceptors.response, + id, + ); + }, + clear: () => { + nordusConfigApi.interceptors.response = []; + }, + }; +} + +export function createInterceptors(nordusConfig: NordusConfig) { + const interceptorsRequest = asArray(nordusConfig.interceptors?.request); + const interceptorsResponse = asArray(nordusConfig.interceptors?.response); + if (!nordusConfig.interceptors) + nordusConfig.interceptors = { + request: interceptorsRequest, + response: interceptorsResponse, + }; +} + +export function addInterceptor(list: T[], interceptor: T) { + list.push(interceptor); + const uuid = randomUUID(); + Object.defineProperty(interceptor, "__interceptorId", { + value: uuid, + }); + return uuid; +} + +export function removeInterceptor(interceptor: T[], id: string) { + return interceptor.filter((element) => { + return getValueByKey("__interceptorId", element) != id; + }); +} diff --git a/src/request.ts b/src/request.ts index 3f180bb..db88c6f 100644 --- a/src/request.ts +++ b/src/request.ts @@ -30,7 +30,14 @@ export interface NordusConfig extends RequestInit { timeout?: number; } -export interface NordusConfigApi extends NordusConfig {} +interface InterceptorsApi { + request: InterceptorRequest[]; + response: InterceptorResponse[]; +} + +export interface NordusConfigApi extends NordusConfig { + interceptors: InterceptorsApi; +} export interface NordusResponse extends Response { data?: T; @@ -84,6 +91,7 @@ export class NordusRequest { nordusConfigApi: NordusConfigApi, abort: AbortTimeout, ) { + let error, request; try { const urlRequest = this.generateURL(url, nordusConfigApi); const body = this.getBody(nordusConfigApi); @@ -96,15 +104,16 @@ export class NordusRequest { ...nordusConfigApi, }; - const request = new Request(urlRequest, init); + request = new Request(urlRequest, init); this.setHeaders(nordusConfigApi, request); this.setRequestType(request, nordusConfigApi); - await this.executeLoopAsync(this.interceptorRequest, null, request); - return request; - } catch (error: any) { - await this.executeLoopAsync(this.interceptorRequest, error, null); - throw error; + } catch (err: any) { + error = err; + } finally { + await this.executeLoopAsync(this.interceptorRequest, error, request); + if (error) throw error; + return request!; } } @@ -114,8 +123,9 @@ export class NordusRequest { abort: AbortTimeout, ) { const timeoutId = abort.start(); + let error, response; try { - const response = (await fetch(request)) as NordusResponse; + response = (await fetch(request)) as NordusResponse; clearTimeout(timeoutId); if (!response.ok) throw new Error(response.statusText); @@ -123,12 +133,13 @@ export class NordusRequest { response, nordusConfigApi, ); - await this.executeLoopAsync(this.interceptorsResponse, null, response); - return response; - } catch (error: any) { + } catch (err: any) { + error = err; clearTimeout(timeoutId); - await this.executeLoopAsync(this.interceptorsResponse, error, null); - throw error; + } finally { + await this.executeLoopAsync(this.interceptorsResponse, error, response); + if (error) throw error; + return response!; } } diff --git a/src/utils.ts b/src/utils.ts index 89d5ec9..62da47e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,30 @@ +import crypto from "crypto"; + export function append(arr: T[], element: T | T[]) { if (Array.isArray(element)) { arr.push(...element); } else { arr.push(element); } + return arr; +} + +export function randomUUID() { + return crypto.randomUUID(); +} + +export function propertyOf(name: keyof TObj) { + return name; +} + +export function getValueByKey(key: string, obj: any) { + const attr = key as keyof typeof obj; + const __interceptorId = obj[attr]; + return __interceptorId; +} + +export function asArray(obj: any) { + if (!obj) return []; + if (Array.isArray(obj)) return obj; + return [obj]; } diff --git a/tests/helpers/utils.ts b/tests/helpers/utils.ts new file mode 100644 index 0000000..98eaa4f --- /dev/null +++ b/tests/helpers/utils.ts @@ -0,0 +1,3 @@ +export function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tests/interceptors.spec.ts b/tests/interceptors.spec.ts index 5b62389..1aafa3a 100644 --- a/tests/interceptors.spec.ts +++ b/tests/interceptors.spec.ts @@ -1,6 +1,7 @@ -import { create, post } from "../src"; +import { create, get, post } from "../src"; import { FetchMock } from "jest-fetch-mock"; import { InterceptorRequest, InterceptorResponse } from "../src/request"; +import { delay } from "./helpers/utils"; describe("interceptors", () => { const fetchMock = fetch as FetchMock; @@ -241,4 +242,250 @@ describe("interceptors", () => { }); expect(textInterceptor).toEqual("123"); }); + + it("shoud allow to add interceptors by use and parameter for request", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + + let textInterceptor = ""; + const firstInterceptor: InterceptorRequest = () => { + textInterceptor += "1"; + }; + const secondInterceptor: InterceptorRequest = () => { + textInterceptor += "2"; + }; + + const instance = create({ + baseURL: "http://localhost:5000", + }); + instance.interceptors.request.use(firstInterceptor); + await instance.get("/todos/1", { + interceptors: { + request: secondInterceptor, + }, + }); + expect(textInterceptor).toEqual("12"); + }); + + it("shoud allow to add interceptors by use and parameter for request", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + + let textInterceptor = ""; + const firstInterceptor: InterceptorRequest = () => { + textInterceptor += "1"; + }; + const secondInterceptor: InterceptorRequest = () => { + textInterceptor += "2"; + }; + + const instance = create({ + baseURL: "http://localhost:5000", + }); + const id = instance.interceptors.request.use(firstInterceptor); + instance.interceptors.request.eject(id); + + await instance.get("/todos/1", { + interceptors: { + request: secondInterceptor, + }, + }); + expect(textInterceptor).toEqual("2"); + }); + + it("shoud allow to clear interceptors for request", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + + let textInterceptor = ""; + const firstInterceptor: InterceptorRequest = () => { + textInterceptor += "1"; + }; + const secondInterceptor: InterceptorRequest = () => { + textInterceptor += "2"; + }; + + const instance = create({ + baseURL: "http://localhost:5000", + interceptors: { + request: [firstInterceptor], + }, + }); + instance.interceptors.request.use(secondInterceptor); + instance.interceptors.request.clear(); + + await instance.get("/todos/1"); + expect(textInterceptor).toEqual(""); + }); + + it("shoud allow to add interceptors by use and parameter for response", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + + let textInterceptor = ""; + const firstInterceptor: InterceptorRequest = () => { + textInterceptor += "1"; + }; + const secondInterceptor: InterceptorRequest = () => { + textInterceptor += "2"; + }; + + const instance = create({ + baseURL: "http://localhost:5000", + }); + instance.interceptors.request.use(firstInterceptor); + await instance.get("/todos/1", { + interceptors: { + request: secondInterceptor, + }, + }); + expect(textInterceptor).toEqual("12"); + }); + + it("shoud allow to add interceptors by use and parameter for response", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + + let textInterceptor = ""; + const firstInterceptor: InterceptorRequest = () => { + textInterceptor += "1"; + }; + const secondInterceptor: InterceptorRequest = () => { + textInterceptor += "2"; + }; + + const instance = create({ + baseURL: "http://localhost:5000", + }); + const id = instance.interceptors.request.use(firstInterceptor); + instance.interceptors.request.eject(id); + + await instance.get("/todos/1", { + interceptors: { + request: secondInterceptor, + }, + }); + expect(textInterceptor).toEqual("2"); + }); + + it("shoud allow to clear interceptors for response", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + + let textInterceptor = ""; + const firstInterceptor: InterceptorRequest = () => { + textInterceptor += "1"; + }; + const secondInterceptor: InterceptorRequest = () => { + textInterceptor += "2"; + }; + + const instance = create({ + baseURL: "http://localhost:5000", + interceptors: { + request: [firstInterceptor], + }, + }); + instance.interceptors.request.use(secondInterceptor); + instance.interceptors.request.clear(); + + await instance.get("/todos/1"); + expect(textInterceptor).toEqual(""); + }); + + it("shoud possible to change headers on async interceptors on create instance", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + const instance = create({ + baseURL: "http://localhost:5000", + interceptors: { + request: async (err, request) => { + await delay(1); + request.headers.set("access_token", "Bearer 123"); + }, + response: async (err, response) => { + await delay(1); + return response; + }, + }, + }); + await instance.get("/todos/1"); + const lastCall = fetchMock?.mock?.lastCall?.at(0) as Request; + expect(lastCall?.headers.get("access_token")).toEqual("Bearer 123"); + }); + + it("shoud possible to change headers on get request with interceptors", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + await get("http://localhost:5000/todos/1", { + interceptors: { + request: async (err, request) => { + await delay(1); + request.headers.set("access_token", "Bearer 123"); + }, + response: async (err, response) => { + await delay(1); + return response; + }, + }, + }); + const lastCall = fetchMock?.mock?.lastCall?.at(0) as Request; + expect(lastCall?.headers.get("access_token")).toEqual("Bearer 123"); + }); + + it("shoud possible to throw error on request", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + + let textInterceptorRequest = ""; + const firstInterceptorRequest: InterceptorRequest = () => { + textInterceptorRequest += "1"; + throw new Error("Error on interceptor"); + }; + const secondInterceptorRequest: InterceptorRequest = () => { + textInterceptorRequest += "2"; + }; + + let textInterceptorResponse = ""; + const firstInterceptorResponse: InterceptorResponse = () => { + textInterceptorResponse += "1"; + }; + try { + await get("http://localhost:5000/todos/1", { + interceptors: { + request: [firstInterceptorRequest, secondInterceptorRequest], + response: [firstInterceptorResponse], + }, + }); + } catch (error) { + expect(error.message).toEqual("Error on interceptor"); + } + expect(textInterceptorRequest).toEqual("1"); + expect(textInterceptorResponse).toEqual(""); + }); + + it("shoud possible to throw error on response", async () => { + fetchMock.mockResponseOnce(JSON.stringify({ test: "test" })); + + let textInterceptorRequest = ""; + const firstInterceptorRequest: InterceptorRequest = () => { + textInterceptorRequest += "1"; + }; + const secondInterceptorRequest: InterceptorRequest = () => { + textInterceptorRequest += "2"; + }; + + let textInterceptorResponse = ""; + const firstInterceptorResponse: InterceptorResponse = () => { + textInterceptorResponse += "1"; + throw new Error("Error on interceptor"); + }; + const secondInterceptorResponse: InterceptorResponse = () => { + textInterceptorResponse += "2"; + }; + + try { + await get("http://localhost:5000/todos/1", { + interceptors: { + request: [firstInterceptorRequest, secondInterceptorRequest], + response: [firstInterceptorResponse, secondInterceptorResponse], + }, + }); + } catch (error) { + expect(error.message).toEqual("Error on interceptor"); + } + expect(textInterceptorRequest).toEqual("12"); + expect(textInterceptorResponse).toEqual("1"); + }); });