diff --git a/README.md b/README.md index 9442e64..4e39b41 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## Intro -A lite request lib based on **fetch** with plugins support. +A lite request lib based on **fetch** with plugin support and similar API to axios. **Features:** diff --git a/package.json b/package.json index 9936e4a..0a4c826 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xior", "version": "0.3.12", - "description": "A lite request lib based on fetch with plugins support, and axios similar API", + "description": "A lite request lib based on fetch with plugin support and similar API to axios.", "repository": "suhaotian/xior", "bugs": "https://github.com/suhaotian/xior/issues", "homepage": "https://github.com/suhaotian/xior", diff --git a/src/types.ts b/src/types.ts index 8e42670..b704028 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,3 +46,20 @@ export type XiorPlugin = ( adapter: (request: XiorRequestConfig) => Promise, instance?: XiorInstance ) => (request: XiorRequestConfig) => Promise>; + +export interface XiorInterceptorOptions { + /** @deprecated useless here */ + synchronous?: boolean; + /** @deprecated useless here */ + runWhen?: (config: XiorInterceptorRequestConfig) => boolean; +} + +export interface XiorResponseInterceptorConfig { + data: T; + config: XiorInterceptorRequestConfig; + request: XiorInterceptorRequestConfig; + response: Response; + status: number; + statusText: string; + headers: Headers; +} diff --git a/src/xior.ts b/src/xior.ts index a67f27b..b1549a9 100644 --- a/src/xior.ts +++ b/src/xior.ts @@ -12,16 +12,39 @@ import { import defaultRequestInterceptor from './interceptors'; import type { + XiorInterceptorOptions, XiorInterceptorRequestConfig, XiorPlugin, XiorRequestConfig, XiorResponse, + XiorResponseInterceptorConfig, } from './types'; const supportAbortController = typeof AbortController !== 'undefined'; - +const undefinedValue = undefined; export type XiorInstance = xior; +async function getData( + response: Response, + responseType?: 'json' | 'text' | 'stream' | 'document' | 'arraybuffer' | 'blob' | 'original' +) { + let data: any; + if (!responseType || !response.ok || ['text', 'json'].includes(responseType)) { + data = await response.text(); + if (data && responseType !== 'text') { + try { + data = JSON.parse(data); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) {} + } + } else if (responseType === 'blob') { + data = await response.blob(); + } else if (responseType === 'arraybuffer') { + data = await response.arrayBuffer(); + } + return data; +} + export class xior { static create(options?: XiorRequestConfig): XiorInstance { return new xior(options); @@ -45,25 +68,10 @@ export class xior { ) => Promise | XiorInterceptorRequestConfig)[] = []; /** response interceptors */ RESI: { - fn: (config: { - data: any; - config: XiorInterceptorRequestConfig; - request: XiorInterceptorRequestConfig; - response: Response; - }) => - | Promise<{ - data: any; - config: XiorInterceptorRequestConfig; - request: XiorInterceptorRequestConfig; - response: Response; - }> - | { - data: any; - config: XiorInterceptorRequestConfig; - request: XiorInterceptorRequestConfig; - response: Response; - }; - onRejected?: (error: XiorError) => any; + fn: ( + config: XiorResponseInterceptorConfig + ) => Promise | XiorResponseInterceptorConfig; + onRejected?: null | ((error: XiorError) => any); }[] = []; get interceptors() { @@ -74,7 +82,8 @@ export class xior { config: XiorInterceptorRequestConfig ) => Promise | XiorInterceptorRequestConfig, /** @deprecated useless here */ - onRejected?: (error: any) => any + onRejected?: null | ((error: any) => any), + options?: XiorInterceptorOptions ) => { this.REQI.push(fn); return fn; @@ -92,48 +101,18 @@ export class xior { }, response: { use: ( - fn: (config: { - data: any; - config: XiorInterceptorRequestConfig; - request: XiorInterceptorRequestConfig; - response: Response; - }) => - | Promise<{ - data: any; - config: XiorInterceptorRequestConfig; - request: XiorInterceptorRequestConfig; - response: Response; - }> - | { - data: any; - config: XiorInterceptorRequestConfig; - request: XiorInterceptorRequestConfig; - response: Response; - }, - onRejected?: (error: XiorError) => any + fn: ( + config: XiorResponseInterceptorConfig + ) => Promise | XiorResponseInterceptorConfig, + onRejected?: null | ((error: XiorError) => any) ) => { this.RESI.push({ fn, onRejected }); return fn; }, eject: ( - fn: (config: { - data: any; - config: XiorInterceptorRequestConfig; - request: XiorInterceptorRequestConfig; - response: Response; - }) => - | Promise<{ - data: any; - config: XiorInterceptorRequestConfig; - request: XiorInterceptorRequestConfig; - response: Response; - }> - | { - data: any; - config: XiorInterceptorRequestConfig; - request: XiorInterceptorRequestConfig; - response: Response; - } + fn: ( + config: XiorResponseInterceptorConfig + ) => Promise | XiorResponseInterceptorConfig ) => { this.RESI = this.RESI.filter((item) => item.fn !== fn); }, @@ -201,7 +180,7 @@ export class xior { /** timeout */ let signal: AbortSignal; const signals: AbortSignal[] = []; - let timer: ReturnType | undefined = undefined; + let timer: ReturnType | undefined = undefinedValue; if (timeout && supportAbortController) { const controller = new AbortController(); timer = setTimeout(() => { @@ -224,21 +203,29 @@ export class xior { finalURL = joinPath(requestConfig.baseURL, finalURL); } + const handleResponseRejects = async (error: XiorError) => { + let hasReject = false; + for (const item of this.RESI) { + if (item.onRejected) { + hasReject = true; + const res = await item.onRejected(error); + if (res?.response?.ok) return res; + } + } + if (!hasReject) throw error; + }; + let response: Response; try { response = await fetch(finalURL, { - body: isGet ? undefined : _data, + body: isGet ? undefinedValue : _data, ...rest, signal, method, headers, }); } catch (e) { - for (const item of this.RESI) { - if (item.onRejected) { - await item.onRejected(e as XiorError); - } - } + await handleResponseRejects(e as XiorError); throw e; } finally { if (timer) clearTimeout(timer); @@ -252,15 +239,10 @@ export class xior { statusText: response.statusText, headers: response.headers, }; + const { responseType } = requestConfig; if (!response.ok) { - let data: any = undefined; - try { - data = await response.text(); - if (data) { - data = JSON.parse(data); - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) {} + const data = await getData(response, responseType); + const error = new XiorError( !response.status ? `Network error` : `Request failed with status code ${response.status}`, requestConfig, @@ -269,57 +251,45 @@ export class xior { ...commonRes, } ); - for (const item of this.RESI) { - if (item.onRejected) { - const res = await item.onRejected(error); - if (res?.response?.ok) return res; - } - } + await handleResponseRejects(error); throw error; } if (method === 'HEAD') { return { - data: undefined as T, + data: undefinedValue as T, request: requestConfig, ...commonRes, }; } - const { responseType } = requestConfig; - if (!responseType || ['json', 'text'].includes(responseType)) { - let data: any; - try { - data = (await response.text()) as T; - if (data && responseType !== 'text') { - data = JSON.parse(data as string); - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - // - } - let responseObj = { - data: data as T, + const result = await getData(response, responseType); + + try { + let lastRes = { + ...commonRes, + data: result as T, config: requestConfig as XiorInterceptorRequestConfig, request: requestConfig as XiorInterceptorRequestConfig, response, }; - for (const item of this.RESI) { - responseObj = await item.fn(responseObj); + // eslint-disable-next-line no-inner-declarations + async function run() { + return item.fn(lastRes); + } + const res = await run().catch(async (error) => { + await handleResponseRejects(error as XiorError); + }); + if (res) { + lastRes = res; + } } - return { - data: responseObj.data, - request: requestConfig, - ...commonRes, - }; + return lastRes; + } catch (error) { + await handleResponseRejects(error as XiorError); + throw error; } - - return { - data: undefined as T, - request: requestConfig, - ...commonRes, - }; } /** create get method */ diff --git a/tests/src/tests/index.test.ts b/tests/src/tests/index.test.ts index bdda521..51469e8 100644 --- a/tests/src/tests/index.test.ts +++ b/tests/src/tests/index.test.ts @@ -390,6 +390,53 @@ describe('xior tests', () => { }); }); + describe('`responseType` should work', () => { + it('should work with `responseType: "blob"`', async () => { + const xiorInstance = xior.create({ baseURL }); + const { data: getData } = await xiorInstance.get('/get', { + responseType: 'blob', + }); + assert.strictEqual(getData instanceof Blob, true); + + const { data: postData } = await xiorInstance.post( + '/post', + {}, + { responseType: 'blob' } + ); + assert.strictEqual(postData instanceof Blob, true); + }); + + it('should work with `responseType: "arraybuffer"`', async () => { + const xiorInstance = xior.create({ baseURL }); + const { data: getData } = await xiorInstance.get('/get', { + responseType: 'arraybuffer', + }); + assert.strictEqual(getData instanceof ArrayBuffer, true); + + const { data: postData } = await xiorInstance.post( + '/post', + {}, + { responseType: 'arraybuffer' } + ); + assert.strictEqual(postData instanceof ArrayBuffer, true); + }); + + it('should work with as expected `responseType: "original"` or `responseType: "stream"`', async () => { + const xiorInstance = xior.create({ baseURL }); + const { data: getData } = await xiorInstance.get('/get', { + responseType: 'original', + }); + assert.strictEqual(getData === undefined, true); + + const { data: postData } = await xiorInstance.post( + '/post', + {}, + { responseType: 'stream' } + ); + assert.strictEqual(postData === undefined, true); + }); + }); + describe('custom plugins should work', () => { it('should work with custom plugin', async () => { const xiorInstance = xior.create({ baseURL }); diff --git a/tests/src/tests/interceptors.test.ts b/tests/src/tests/interceptors.test.ts new file mode 100644 index 0000000..1086381 --- /dev/null +++ b/tests/src/tests/interceptors.test.ts @@ -0,0 +1,373 @@ +import assert from 'node:assert'; +import { after, afterEach, before, describe, it } from 'node:test'; +import xior, { XiorResponse } from 'xior'; + +import { startServer } from './server'; + +const axios = xior.create(); + +let close: Function; +const port = 7868; +const baseURL = `http://localhost:${port}`; +axios.defaults.baseURL = baseURL; +before(async () => { + close = await startServer(port); +}); + +after(async () => { + return close(1); +}); + +afterEach(function () { + axios.interceptors.request.clear(); + axios.interceptors.response.clear(); +}); +describe('interceptors', function () { + it('should execute asynchronously when not all interceptors are explicitly flagged as synchronous', async function () { + axios.interceptors.request.use(async function (config) { + config.headers.foo = 'uh oh, async'; + + return config; + }); + + axios.interceptors.request.use(async function (config) { + config.headers.test = 'added by the async interceptor'; + return config; + }); + + const { config } = await axios.request('/get'); + assert.equal(config.headers.foo, 'uh oh, async'); + assert.equal(config.headers.test, 'added by the async interceptor'); + }); + + it('should add a request interceptor that returns a promise', async function (done) { + axios.interceptors.request.use(function (config) { + return new Promise(function (resolve) { + // do something async + setTimeout(function () { + config.headers.async = 'promise'; + resolve(config); + }, 100); + }); + }); + + await axios.request('/get').then(({ config: request }) => { + assert.equal(request.headers.async, 'promise'); + }); + }); + + it('should add multiple request interceptors', function (done) { + axios.interceptors.request.use(function (config) { + config.headers.test1 = '1'; + return config; + }); + axios.interceptors.request.use(function (config) { + config.headers.test2 = '2'; + return config; + }); + axios.interceptors.request.use(function (config) { + config.headers.test3 = '3'; + return config; + }); + + axios.request('/get').then(({ config: request }) => { + assert.equal(request.headers.test1, '1'); + assert.equal(request.headers.test2, '2'); + assert.equal(request.headers.test3, '3'); + }); + }); + + it('should add a response interceptor', async function () { + let response: XiorResponse | undefined; + + axios.interceptors.response.use(function (data) { + data.data = data.data.method + ' - modified by interceptor'; + return data; + }); + + await axios.request('/get').then(function (data) { + response = data; + }); + + assert.equal(response?.data, 'get - modified by interceptor'); + }); + + it('should add a response interceptor when request interceptor is defined', async function () { + let response: XiorResponse | undefined; + + axios.interceptors.request.use(function (config) { + return config; + }); + + axios.interceptors.response.use(function (data) { + data.data = data.data.method + ' - modified by interceptor'; + return data; + }); + + await axios.request('/get').then(function (data) { + response = data; + }); + + assert.equal(response?.data, 'get - modified by interceptor'); + }); + + it('should add a response interceptor that returns a new data object', async function () { + let response: XiorResponse | undefined; + + axios.interceptors.response.use(function () { + return { + data: 'stuff', + } as any; + }); + + await axios.request('/get').then(function (data) { + response = data; + }); + assert.equal(response?.data, 'stuff'); + }); + + it('should add a response interceptor that returns a promise', async function () { + let response: XiorResponse | undefined; + + axios.interceptors.response.use(function (data) { + return new Promise(function (resolve) { + // do something async + setTimeout(function () { + data.data = 'you have been promised!'; + resolve(data); + }, 10); + }); + }); + + await axios.request('/get').then(function (data) { + response = data; + }); + assert.equal(response?.data, 'you have been promised!'); + }); + + describe('given you add multiple response interceptors', function () { + describe('and when the response was fulfilled', function () { + it('then each interceptor is executed', async function () { + let fired1 = 0; + axios.interceptors.response.use((r) => { + fired1 = 1; + return r; + }); + let fired2 = 0; + axios.interceptors.response.use((r) => { + fired2 = 2; + return r; + }); + await axios.get('/get'); + assert.equal(fired1, 1); + assert.equal(fired2, 2); + }); + + it('then they are executed in the order they were added', async function () { + const fired = [] as number[]; + axios.interceptors.response.use((r) => { + fired.push(1); + return r; + }); + axios.interceptors.response.use((r) => { + fired.push(2); + return r; + }); + await axios.get('/get'); + assert.equal(fired[1] > fired[0], true); + }); + + it("then only the last interceptor's result is returned", async function () { + axios.interceptors.response.use(function () { + return 'response 1' as any; + }); + axios.interceptors.response.use(function (response) { + return 'response 2' as any; + }); + const res = await axios.get('/get'); + assert.equal(res as any, 'response 2'); + }); + + it("then every interceptor receives the result of it's predecessor", async function () { + axios.interceptors.response.use(function () { + return 'response 1' as any; + }); + axios.interceptors.response.use(function (response) { + return [response, 'response 2'] as any; + }); + const res = await axios.get('/get'); + assert.equal((res as any).join(','), ['response 1', 'response 2'].join(',')); + }); + + describe('and when the fulfillment-interceptor throws', function () { + it('then the following fulfillment-interceptor is not called', async function () { + axios.interceptors.response.use(function () { + throw Error('throwing interceptor'); + }); + + let fired = false; + axios.interceptors.response.use(async function interceptor2(config) { + fired = true; + return config; + }); + + try { + await axios.get('/get'); + } catch (e) { + // + } + assert.equal(fired, false); + }); + + it('then the following rejection-interceptor is called', async function () { + axios.interceptors.response.use(function () { + throw Error('throwing interceptor'); + }); + const unusedFulfillInterceptor = async function (r: any) { + return r; + }; + + let fired = false; + const rejectIntercept = function () { + fired = true; + }; + axios.interceptors.response.use(unusedFulfillInterceptor, rejectIntercept); + + try { + await axios.get('/get'); + } catch (e) { + // + } + assert.equal(fired, true); + }); + + it('once caught, another following fulfill-interceptor is called again (just like in a promise chain)', async function () { + axios.interceptors.response.use(function () { + throw Error('throwing interceptor'); + }); + + const unusedFulfillInterceptor = async function (r: any) { + return r; + }; + const catchingThrowingInterceptor = function () {}; + axios.interceptors.response.use(unusedFulfillInterceptor, catchingThrowingInterceptor); + + let fired = false; + axios.interceptors.response.use(function interceptor3(res) { + fired = true; + return res; + }); + try { + await axios.get('/get'); + } catch (e) { + // + } + assert.equal(fired, true); + }); + }); + }); + }); + + it('should allow removing interceptors', async function () { + let response: XiorResponse | undefined; + + axios.interceptors.response.use(function (data) { + const resultData = data.data.method + '1'; + return { ...data, data: resultData }; + }); + const intercept = axios.interceptors.response.use(function (data) { + data.data = data.data + '2'; + return { ...data, data: data.data }; + }); + axios.interceptors.response.use(function (data) { + const resultData = data.data + '3'; + return { ...data, data: resultData }; + }); + + axios.interceptors.response.eject(intercept); + + await axios.request('/get?a=123').then(function (data) { + response = data; + }); + console.log('response.data', response); + + assert.equal(response?.data, 'get13'); + }); + + // it('should remove async interceptor before making request and execute synchronously', function (done) { + // let asyncFlag = false; + // const asyncIntercept = axios.interceptors.request.use( + // function (config) { + // config.headers.async = 'async it!'; + // return config; + // }, + // null, + // { synchronous: false } + // ); + + // const syncIntercept = axios.interceptors.request.use( + // function (config) { + // config.headers.sync = 'hello world'; + // expect(asyncFlag).toBe(false); + // return config; + // }, + // null, + // { synchronous: true } + // ); + + // axios.interceptors.request.eject(asyncIntercept); + + // axios('/foo'); + // asyncFlag = true; + + // getAjaxRequest().then(function (request) { + // expect(request.requestHeaders.async).toBeUndefined(); + // expect(request.requestHeaders.sync).toBe('hello world'); + // done(); + // }); + // }); + + // it('should execute interceptors before transformers', async function () { + // axios.interceptors.request.use(function (config) { + // config.data.baz = 'qux'; + // return config; + // }); + + // const {config: request} = await axios.post('/post', { + // foo: 'bar', + // }); + + // getAjaxRequest().then(function (request) { + // expect(request.params).toEqual('{"foo":"bar","baz":"qux"}'); + // (); + // }); + // }); + + it('should modify base URL in request interceptor', async function () { + const instance = xior.create({ + baseURL: 'http://test.com/', + }); + + instance.interceptors.request.use(function (config) { + config.baseURL = baseURL; + return config; + }); + + await instance.get('/get').then(({ config: request }) => { + assert.equal(request.baseURL, baseURL); + assert.equal(request.url, '/get'); + }); + }); + + it('should clear all response interceptors', function () { + const instance = xior.create({ + baseURL: 'http://test.com/', + }); + + instance.interceptors.response.use(function (config) { + return config; + }); + instance.interceptors.response.clear(); + assert.equal(instance.RESI, 0); + }); +});