From 2ddc954f11f2da2e17a5cc10f3f5dcc9344a79e0 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 00:40:31 +0200 Subject: [PATCH 01/16] chore: Add Worker Threads to speed up tests --- jest.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jest.config.js b/jest.config.js index e47141f..3f7d373 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,4 +2,6 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + workerThreads: true, + coverageReporters: ['lcov', 'text', 'html'], }; From dd0525ca4bc29740ca957b5fd152ada2ff6dd3ff Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 00:41:01 +0200 Subject: [PATCH 02/16] refactor: Incorrect naming of variable in API request() method --- src/api-handler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api-handler.ts b/src/api-handler.ts index d9bf576..932ac4b 100644 --- a/src/api-handler.ts +++ b/src/api-handler.ts @@ -9,7 +9,7 @@ import type { ApiHandlerMethods, ApiHandlerReturnType, APIResponse, - QueryParams, + QueryParamsOrBody, UrlPathParams, } from './types/api-handler'; @@ -101,14 +101,14 @@ function createApiFetcher< * It considers settings in following order: per-request settings, global per-endpoint settings, global settings. * * @param {string} endpointName - The name of the API endpoint to call. - * @param {QueryParams} [queryParams={}] - Query parameters to include in the request. + * @param {QueryParamsOrBody} [data={}] - Query parameters to include in the request. * @param {UrlPathParams} [urlPathParams={}] - URI parameters to include in the request. * @param {EndpointConfig} [requestConfig={}] - Additional configuration for the request. * @returns {Promise} - A promise that resolves with the response from the API provider. */ async function request( endpointName: keyof EndpointsMethods | string, - queryParams: QueryParams = {}, + data: QueryParamsOrBody = {}, urlPathParams: UrlPathParams = {}, requestConfig: RequestConfig = {}, ): Promise> { @@ -118,7 +118,7 @@ function createApiFetcher< const responseData = await requestHandler.request( endpointSettings.url, - queryParams, + data, { ...endpointSettings, ...requestConfig, From 13e89b43a3b671a0fb3a2c2030dc27c8d5de75fe Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 00:41:49 +0200 Subject: [PATCH 03/16] fix: fetchf() wrapper should not make assumptions regarding request data/params --- src/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 26a8572..5c00749 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,11 +13,7 @@ export async function fetchf( url: string, config: RequestHandlerConfig = {}, ): Promise> { - return new RequestHandler(config).request( - url, - config.body || config.data || config.params, - config, - ); + return new RequestHandler(config).request(url, null, config); } export * from './types'; From b93a725811a85098a91d051e5bcf0acd49c53471 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 00:46:27 +0200 Subject: [PATCH 04/16] fix: Correctly append query params to POST requests, if data/body are specified --- src/request-handler.ts | 98 ++++++++++++++++++++---------------------- 1 file changed, 46 insertions(+), 52 deletions(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index 9022960..b9564f8 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -200,51 +200,56 @@ export class RequestHandler { /** * Build request configuration * - * @param {string} url Request url - * @param {QueryParamsOrBody} data Request data - * @param {RequestConfig} config Request config - * @returns {RequestConfig} Provider's instance + * @param {string} url - Request url + * @param {QueryParamsOrBody} data - Query Params in case of GET and HEAD requests, body payload otherwise + * @param {RequestConfig} config - Request config passed when making the request + * @returns {RequestConfig} - Provider's instance */ protected buildConfig( url: string, data: QueryParamsOrBody, config: RequestConfig, ): RequestConfig { - const method = config.method || this.method; - const methodLowerCase = method.toLowerCase(); - const isGetAlikeMethod = - methodLowerCase === 'get' || methodLowerCase === 'head'; + const method = (config.method || this.method).toUpperCase(); + const isGetAlikeMethod = method === 'GET' || method === 'HEAD'; const dynamicUrl = replaceUrlPathParams( url, config.urlPathParams || this.config.urlPathParams, ); - // Bonus: Specifying it here brings support for "body" in Axios - const configData = + // The explicitly passed "params" + const explicitParams = config.params || this.config.params; + + // The explicitly passed "body" or "data" + const explicitBodyData = config.body || config.data || this.config.body || this.config.data; - // Axios compatibility + // For convenience, in POST requests the body payload is the "data" + // In edge cases we want to use Query Params in the POST requests + // and use explicitly passed "body" or "data" from request config + const shouldTreatDataAsParams = + data && (isGetAlikeMethod || explicitBodyData) ? true : false; + + // Final body data + let body: RequestConfig['data']; + + // Only applicable for request methods 'PUT', 'POST', 'DELETE', and 'PATCH' + if (!isGetAlikeMethod) { + body = explicitBodyData || data; + } + if (this.isCustomFetcher()) { return { ...config, + method, url: dynamicUrl, - method: methodLowerCase, - - ...(isGetAlikeMethod ? { params: data } : {}), - - // For POST requests body payload is the first param for convenience ("data") - // In edge cases we want to split so to treat it as query params, and use "body" coming from the config instead - ...(!isGetAlikeMethod && data && configData ? { params: data } : {}), - - // Only applicable for request methods 'PUT', 'POST', 'DELETE', and 'PATCH' - ...(!isGetAlikeMethod && data && !configData ? { data } : {}), - ...(!isGetAlikeMethod && configData ? { data: configData } : {}), + params: shouldTreatDataAsParams ? data : explicitParams, + data: body, }; } // Native fetch - const payload = configData || data; const credentials = config.withCredentials || this.config.withCredentials ? 'include' @@ -254,46 +259,36 @@ export class RequestHandler { delete config.withCredentials; const urlPath = - (!isGetAlikeMethod && data && !config.body) || !data - ? dynamicUrl - : appendQueryParams(dynamicUrl, data); + explicitParams || shouldTreatDataAsParams + ? appendQueryParams(dynamicUrl, explicitParams || data) + : dynamicUrl; const isFullUrl = urlPath.includes('://'); - const baseURL = isFullUrl - ? '' - : typeof config.baseURL !== 'undefined' - ? config.baseURL - : this.baseURL; + const baseURL = isFullUrl ? '' : config.baseURL || this.baseURL; + + // Automatically stringify request body, if possible and when not dealing with strings + if ( + body && + typeof body !== 'string' && + !(body instanceof URLSearchParams) && + isJSONSerializable(body) + ) { + body = JSON.stringify(body); + } return { ...config, credentials, + body, + method, - // Native fetch generally requires query params to be appended in the URL - // Do not append query params only if it's a POST-alike request with only "data" specified as it's treated as body payload url: baseURL + urlPath, - // Uppercase method name - method: method.toUpperCase(), - - // For convenience, add the same default headers as Axios does + // Add sensible defaults headers: { Accept: APPLICATION_JSON + ', text/plain, */*', 'Content-Type': APPLICATION_JSON + ';charset=utf-8', ...(config.headers || this.config.headers || {}), }, - - // Automatically JSON stringify request bodies, if possible and when not dealing with strings - ...(!isGetAlikeMethod - ? { - body: - !(payload instanceof URLSearchParams) && - isJSONSerializable(payload) - ? typeof payload === 'string' - ? payload - : JSON.stringify(payload) - : payload, - } - : {}), }; } @@ -457,9 +452,8 @@ export class RequestHandler { * Handle Request depending on used strategy * * @param {string} url - Request url - * @param {QueryParamsOrBody} data - Request data + * @param {QueryParamsOrBody} data - Query Params in case of GET and HEAD requests, body payload otherwise * @param {RequestConfig} config - Request config - * @param {RequestConfig} payload.config Request config * @throws {ResponseError} * @returns {Promise>} Response Data */ From e91af6259a723cd78c490df61987bf134f54d802 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 00:47:11 +0200 Subject: [PATCH 05/16] feat: More strongly typed query params in fetchf() requests by default --- src/types/request-handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index 2b5124a..9f64027 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { UrlPathParams } from './api-handler'; +import type { QueryParams, UrlPathParams } from './api-handler'; import type { RequestInterceptor, ResponseInterceptor, @@ -46,7 +46,7 @@ export interface BaseRequestConfig { transformRequest?: Transformer | Transformer[]; transformResponse?: Transformer | Transformer[]; headers?: HeadersInit; - params?: any; + params?: QueryParams; paramsSerializer?: (params: any) => string; data?: D; timeout?: number; From 9be96871adc8df2196bf339d251a0e096aec8ae6 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 00:50:38 +0200 Subject: [PATCH 06/16] fix: Tests for improved query params handling --- test/request-handler.spec.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 2dcf664..687a48d 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -155,7 +155,7 @@ describe('Request Handler', () => { }); }); - it('should handle custom headers and config', () => { + it('should handle custom headers and config when both data and query params are passed', () => { const result = buildConfig( 'POST', 'https://example.com/api', @@ -167,7 +167,7 @@ describe('Request Handler', () => { ); expect(result).toEqual({ - url: 'https://example.com/api', + url: 'https://example.com/api?foo=bar', method: 'POST', headers: { Accept: 'application/json, text/plain, */*', @@ -179,12 +179,7 @@ describe('Request Handler', () => { }); it('should handle empty data and config', () => { - const result = buildConfig( - 'POST', - 'https://example.com/api', - undefined, - {}, - ); + const result = buildConfig('POST', 'https://example.com/api', null, {}); expect(result).toEqual({ url: 'https://example.com/api', @@ -193,7 +188,7 @@ describe('Request Handler', () => { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json;charset=utf-8', }, - body: undefined, + body: null, }); }); @@ -218,7 +213,7 @@ describe('Request Handler', () => { it('should correctly append query params for GET-alike methods', () => { const result = buildConfig( - 'GET', + 'head', 'https://example.com/api', { foo: [1, 2] }, {}, @@ -226,7 +221,7 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api?foo[]=1&foo[]=2', - method: 'GET', + method: 'HEAD', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json;charset=utf-8', @@ -243,7 +238,7 @@ describe('Request Handler', () => { ); expect(result).toEqual({ - url: 'https://example.com/api', + url: 'https://example.com/api?foo=bar', method: 'POST', headers: { Accept: 'application/json, text/plain, */*', From 0bb03605d79bd620b3ca98b57c26c9ed4c28b931 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 01:07:30 +0200 Subject: [PATCH 07/16] fix: Bail parsing data early if there is no response body --- src/request-handler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/request-handler.ts b/src/request-handler.ts index b9564f8..68876ed 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -564,6 +564,11 @@ export class RequestHandler { public async parseData( response: FetchResponse, ): Promise { + // Bail early when body is empty + if (!response.body) { + return null; + } + const contentType = String( (response as Response).headers?.get('Content-Type') || '', ); From 916281958c860b15eda8795439f45b843bb4a4ee Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 01:07:43 +0200 Subject: [PATCH 08/16] fix: Correctly handle charset in the content type headers when parsing response data --- src/request-handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index 68876ed..0779709 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -571,7 +571,8 @@ export class RequestHandler { const contentType = String( (response as Response).headers?.get('Content-Type') || '', - ); + ).split(';')[0]; // Correctly handle charset + let data; // Handle edge case of no content type being provided... We assume JSON here. From fea9d722c51a5c717c7fd3697f2a7b9b0e3bd255 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 01:31:42 +0200 Subject: [PATCH 09/16] fix: Correctly parse Content-Type text/ responses and streams --- src/request-handler.ts | 60 ++++++++--------- test/request-handler.spec.ts | 125 ++++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 34 deletions(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index 0779709..1c2f477 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -575,42 +575,36 @@ export class RequestHandler { let data; - // Handle edge case of no content type being provided... We assume JSON here. - if (!contentType) { - const responseClone = response.clone(); - try { - data = await responseClone.json(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_error) { - // JSON parsing failed, fallback to null - data = null; - } - } - - if (typeof data === 'undefined') { - try { - if ( - contentType.includes(APPLICATION_JSON) || - contentType.includes('+json') - ) { - data = await response.json(); // Parse JSON response - } else if (contentType.includes('multipart/form-data')) { - data = await response.formData(); // Parse as FormData - } else if (contentType.includes('application/octet-stream')) { - data = await response.blob(); // Parse as blob - } else if (contentType.includes('application/x-www-form-urlencoded')) { - data = await response.formData(); // Handle URL-encoded forms - } else if (typeof response.text === 'function') { - data = await response.text(); // Parse as text - } else { + try { + if ( + contentType.includes(APPLICATION_JSON) || + contentType.includes('+json') + ) { + data = await response.json(); // Parse JSON response + } else if (contentType.includes('multipart/form-data')) { + data = await response.formData(); // Parse as FormData + } else if (contentType.includes('application/octet-stream')) { + data = await response.blob(); // Parse as blob + } else if (contentType.includes('application/x-www-form-urlencoded')) { + data = await response.formData(); // Handle URL-encoded forms + } else if (contentType.includes('text/')) { + data = await response.text(); // Parse as text + } else { + try { + const responseClone = response.clone(); + + // Handle edge case of no content type being provided... We assume JSON here. + data = await responseClone.json(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_e) { // Handle streams - data = response.body || response.data || null; + data = await response.text(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_error) { - // Parsing failed, fallback to null - data = null; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_error) { + // Parsing failed, fallback to null + data = null; } return data; diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 687a48d..645bb15 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -2,7 +2,7 @@ import PromiseAny from 'promise-any'; import { RequestHandler } from '../src/request-handler'; import fetchMock from 'fetch-mock'; -import { fetchf } from '../src'; +import { fetchf, FetchResponse } from '../src'; import { interceptRequest, interceptResponse, @@ -984,6 +984,129 @@ describe('Request Handler', () => { }); }); + describe('parseData', () => { + let mockResponse: FetchResponse; + const requestHandler = new RequestHandler({ fetcher }); + + beforeEach(() => { + mockResponse = { + headers: { + get: jest.fn(), + }, + clone: jest.fn(), + json: jest.fn(), + formData: jest.fn(), + blob: jest.fn(), + text: jest.fn(), + body: 'something', + } as unknown as FetchResponse; + }); + + it('should parse JSON response when Content-Type is application/json', async () => { + (mockResponse.headers.get as jest.Mock).mockReturnValue( + 'application/json', + ); + const expectedData = { key: 'value' }; + (mockResponse.json as jest.Mock).mockResolvedValue(expectedData); + + const data = await requestHandler.parseData(mockResponse); + expect(data).toEqual(expectedData); + }); + + it('should parse JSON response when Content-Type is application/vnd.api+json', async () => { + (mockResponse.headers.get as jest.Mock).mockReturnValue( + 'application/vnd.api+json', + ); + const expectedData = { key: 'value' }; + (mockResponse.json as jest.Mock).mockResolvedValue(expectedData); + + const data = await requestHandler.parseData(mockResponse); + expect(data).toEqual(expectedData); + }); + + it('should parse FormData when Content-Type is multipart/form-data', async () => { + (mockResponse.headers.get as jest.Mock).mockReturnValue( + 'multipart/form-data', + ); + const expectedData = new FormData(); + (mockResponse.formData as jest.Mock).mockResolvedValue(expectedData); + + const data = await requestHandler.parseData(mockResponse); + expect(data).toEqual(expectedData); + }); + + it('should parse Blob when Content-Type is application/octet-stream', async () => { + (mockResponse.headers.get as jest.Mock).mockReturnValue( + 'application/octet-stream', + ); + const expectedData = new Blob(['test']); + (mockResponse.blob as jest.Mock).mockResolvedValue(expectedData); + + const data = await requestHandler.parseData(mockResponse); + expect(data).toEqual(expectedData); + }); + + it('should parse FormData when Content-Type is application/x-www-form-urlencoded', async () => { + (mockResponse.headers.get as jest.Mock).mockReturnValue( + 'application/x-www-form-urlencoded', + ); + const expectedData = new FormData(); + (mockResponse.formData as jest.Mock).mockResolvedValue(expectedData); + + const data = await requestHandler.parseData(mockResponse); + expect(data).toEqual(expectedData); + }); + + it('should parse text when Content-Type is text/plain', async () => { + (mockResponse.headers.get as jest.Mock).mockReturnValue('text/plain'); + const expectedData = 'Some plain text'; + (mockResponse.text as jest.Mock).mockResolvedValue(expectedData); + + const data = await requestHandler.parseData(mockResponse); + expect(data).toEqual(expectedData); + }); + + it('should return plain text when Content-Type is missing and JSON parsing fails', async () => { + (mockResponse.headers.get as jest.Mock).mockReturnValue(''); + const responseClone = { + json: jest.fn().mockRejectedValue(new Error('JSON parsing error')), + }; + (mockResponse.clone as jest.Mock).mockReturnValue(responseClone); + + const expectedData = 'Some plain text'; + (mockResponse.text as jest.Mock).mockResolvedValue(expectedData); + + const data = await requestHandler.parseData(mockResponse); + + expect(data).toBe('Some plain text'); + }); + + it('should return null when content type is not recognized and response parsing fails', async () => { + (mockResponse.headers.get as jest.Mock).mockReturnValue( + 'application/unknown-type', + ); + (mockResponse.text as jest.Mock).mockRejectedValue( + new Error('Text parsing error'), + ); + + const data = await requestHandler.parseData(mockResponse); + expect(data).toBeNull(); + }); + + it('should handle streams and return body or data when Content-Type is not recognized', async () => { + (mockResponse.headers.get as jest.Mock).mockReturnValue( + 'application/unknown-type', + ); + + // Mock the `text` method to simulate stream content + const streamContent = 'stream content'; + (mockResponse.text as jest.Mock).mockResolvedValue(streamContent); + + const data = await requestHandler.parseData(mockResponse); + expect(data).toEqual(streamContent); + }); + }); + describe('outputResponse()', () => { it('should show nested data object if flattening is off', async () => { const requestHandler = new RequestHandler({ From f22b6d709a40b52b89f2ed7ee226755e9d9e7eb3 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 01:49:29 +0200 Subject: [PATCH 10/16] fix: Normalize headers keys to lowercase as per IETF RFC 2616 4.2 --- src/request-handler.ts | 19 +++++++----- test/request-handler.spec.ts | 58 +++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index 1c2f477..93556e3 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -613,21 +613,26 @@ export class RequestHandler { public processHeaders( response: FetchResponse, ): HeadersObject { - if (!response.headers) { + const headers = response.headers; + + if (!headers) { return {}; } - let headersObject: HeadersObject = {}; - const headers = response.headers; + const headersObject: HeadersObject = {}; // Handle Headers object with entries() method if (headers instanceof Headers) { - for (const [key, value] of (headers as any).entries()) { + headers.forEach((value, key) => { headersObject[key] = value; - } - } else { + }); + } else if (typeof headers === 'object' && headers !== null) { // Handle plain object - headersObject = { ...(headers as HeadersObject) }; + for (const [key, value] of Object.entries(headers)) { + // Normalize keys to lowercase as per RFC 2616 4.2 + // https://datatracker.ietf.org/doc/html/rfc2616#section-4.2 + headersObject[key.toLowerCase()] = value; + } } return headersObject; diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 645bb15..aae360a 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -984,7 +984,7 @@ describe('Request Handler', () => { }); }); - describe('parseData', () => { + describe('parseData()', () => { let mockResponse: FetchResponse; const requestHandler = new RequestHandler({ fetcher }); @@ -1107,6 +1107,62 @@ describe('Request Handler', () => { }); }); + describe('processHeaders()', () => { + const requestHandler = new RequestHandler({ fetcher }); + + // Test when headers is null or undefined + it('should return an empty object if headers are null or undefined', () => { + const response = { headers: null } as FetchResponse; + const result = requestHandler.processHeaders(response); + expect(result).toEqual({}); + + const responseUndefined = { headers: undefined } as FetchResponse; + const resultUndefined = requestHandler.processHeaders(responseUndefined); + expect(resultUndefined).toEqual({}); + }); + + // Test when headers is an instance of Headers + it('should convert Headers object to a plain object', () => { + const headers = new Headers(); + headers.append('Content-Type', 'application/json'); + headers.append('Authorization', 'Bearer token'); + + const response = { headers } as FetchResponse; + const result = requestHandler.processHeaders(response); + + expect(result).toEqual({ + 'content-type': 'application/json', + authorization: 'Bearer token', + }); + }); + + // Test when headers is a plain object + it('should handle plain object headers', () => { + const response = { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token', + }, + } as unknown as FetchResponse; + + const result = requestHandler.processHeaders(response); + + expect(result).toEqual({ + 'content-type': 'application/json', + authorization: 'Bearer token', + }); + }); + + // Test when headers is an empty Headers object + it('should handle an empty Headers object', () => { + const headers = new Headers(); // Empty Headers + const response = { headers } as FetchResponse; + const result = requestHandler.processHeaders(response); + + expect(result).toEqual({}); + }); + }); + describe('outputResponse()', () => { it('should show nested data object if flattening is off', async () => { const requestHandler = new RequestHandler({ From 5dc2c4862b8c618691f63c92f17f3fdcf4734520 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 02:10:07 +0200 Subject: [PATCH 11/16] feat: Passed query params can handle array of objects with "name" and "value" properties --- src/types/api-handler.ts | 3 +++ test/utils.spec.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/types/api-handler.ts b/src/types/api-handler.ts index 1984896..767a712 100644 --- a/src/types/api-handler.ts +++ b/src/types/api-handler.ts @@ -7,9 +7,12 @@ import type { } from './request-handler'; // Common type definitions +type NameValuePair = { name: string; value: string }; + export declare type QueryParams = | Record | URLSearchParams + | NameValuePair[] | null; export declare type BodyPayload = Record | null; export declare type QueryParamsOrBody = diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 3fff257..8339900 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -288,6 +288,24 @@ describe('Utils', () => { expect(result).toBe('https://api.example.com/resource?foo=bar&baz=qux'); }); + it('should handle params as an instance of URLSearchParams for url with existing query params', () => { + const url = 'https://api.example.com/resource?biz=due'; + const params = new URLSearchParams(); + params.append('foo', 'bar'); + params.append('baz', 'qux'); + const result = appendQueryParams(url, params); + expect(result).toBe( + 'https://api.example.com/resource?biz=due&foo=bar&baz=qux', + ); + }); + + it('should not append question mark when params are empty and instance of URLSearchParams is parsed', () => { + const url = 'https://api.example.com/resource'; + const params = new URLSearchParams(); + const result = appendQueryParams(url, params); + expect(result).toBe(url); + }); + it('should handle complex nested parameters', () => { const url = 'https://api.example.com/resource'; const params = { nested: { foo: 'bar', baz: [1, 2] } }; @@ -296,5 +314,18 @@ describe('Utils', () => { 'https://api.example.com/resource?nested%5Bfoo%5D=bar&nested%5Bbaz%5D[]=1&nested%5Bbaz%5D[]=2', ); }); + + it('should handle array of objects with params', () => { + const url = 'https://api.example.com/resource'; + const params = [ + { name: 'username', value: 'john_doe' }, + { name: 'password', value: 'secure123' }, + ]; + const result = appendQueryParams(url, params); + + expect(result).toBe( + 'https://api.example.com/resource?username=john_doe&password=secure123', + ); + }); }); }); From bb05bc74f577a30042b234b8829891cd3113befe Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 02:10:30 +0200 Subject: [PATCH 12/16] test: Add tests for retry delay invocations --- test/utils.spec.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 8339900..c1ef9e4 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -2,6 +2,7 @@ import { isJSONSerializable, replaceUrlPathParams, appendQueryParams, + delayInvocation, } from '../src/utils'; jest.mock('../src/interceptor-manager', () => ({ @@ -327,5 +328,69 @@ describe('Utils', () => { 'https://api.example.com/resource?username=john_doe&password=secure123', ); }); + + it('should return the same url if empty array is propagated', () => { + const url = 'https://api.example.com/resource'; + const params = []; + const result = appendQueryParams(url, params); + + expect(result).toBe(url); + }); + + it('should return the same url if null is propagated', () => { + const url = 'https://api.example.com/resource'; + const params = null; + const result = appendQueryParams(url, params); + + expect(result).toBe(url); + }); + }); + + describe('delayInvocation()', () => { + // Set up fake timers before all tests + beforeAll(() => { + jest.useFakeTimers(); + }); + + // Clean up fake timers after all tests + afterAll(() => { + jest.useRealTimers(); + }); + + it('should resolve after the specified delay', async () => { + const delay = 100; // 100 milliseconds + + // Call the function but don't wait yet + const promise = delayInvocation(delay); + + // Fast-forward time by 100 milliseconds + jest.advanceTimersByTime(delay); + + // Await the promise and check the result + const result = await promise; + expect(result).toBe(true); + }); + + it('should resolve with true', async () => { + const promise = delayInvocation(100); + + // Fast-forward time by 100 milliseconds + jest.advanceTimersByTime(100); + + // Await the promise and check the result + const result = await promise; + expect(result).toBe(true); + }); + + it('should resolve immediately for zero delay', async () => { + const promise = delayInvocation(0); + + // Fast-forward time by 0 milliseconds (immediate resolve) + jest.advanceTimersByTime(0); + + // Await the promise and check the result + const result = await promise; + expect(result).toBe(true); + }); }); }); From 6a238a2e211f9afec0c6c5b6b724c1185ee49b64 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 02:19:30 +0200 Subject: [PATCH 13/16] docs: Update coverage --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bb1e393..d1b4f2c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Fast, lightweight and reusable data fetching [npm-url]: https://npmjs.org/package/axios-multi-api [npm-image]: http://img.shields.io/npm/v/axios-multi-api.svg -[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/axios-multi-api) [![Code Coverage](https://badgen.now.sh/badge/coverage/94.53/blue)](https://github.com/MattCCC/axios-multi-api) [![npm downloads](https://img.shields.io/npm/dm/axios-multi-api.svg?style=flat-square)](http://npm-stat.com/charts.html?package=axios-multi-api) [![gzip size](https://img.shields.io/bundlephobia/minzip/axios-multi-api)](https://bundlephobia.com/result?p=axios-multi-api) +[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/axios-multi-api) [![Code Coverage](https://badgen.now.sh/badge/coverage/92.53/blue)](https://github.com/MattCCC/axios-multi-api) [![npm downloads](https://img.shields.io/npm/dm/axios-multi-api.svg?style=flat-square)](http://npm-stat.com/charts.html?package=axios-multi-api) [![gzip size](https://img.shields.io/bundlephobia/minzip/axios-multi-api)](https://bundlephobia.com/result?p=axios-multi-api) ## Why? @@ -98,6 +98,7 @@ Note: `axios-multi-api` is designed to seamlessly integrate with any popular libraries like React, Vue, React Query and SWR. It is written in pure JS so you can effortlessly manage API requests with minimal setup, and without any dependencies. ### 🌊 Using with React + You can implement a `useApi()` hook to handle the data fetching. Since this package has everything included, you don't really need anything more than a simple hook to utilize. ```typescript @@ -150,7 +151,6 @@ const ProfileComponent = ({ id }) => { ``` - #### Using with React Query Integrate `axios-multi-api` with React Query to streamline your data fetching: From bfb89f115f2a9ce0926259cd03e6f8a6392aa56b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 02:32:26 +0200 Subject: [PATCH 14/16] test: Add more tests to cfg build --- test/request-handler.spec.ts | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index aae360a..2f42cc5 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -247,6 +247,62 @@ describe('Request Handler', () => { body: JSON.stringify({ additional: 'info' }), }); }); + + it('should append credentials if flag is used', () => { + const result = buildConfig('POST', 'https://example.com/api', null, { + withCredentials: true, + }); + + expect(result).toEqual({ + url: 'https://example.com/api', + method: 'POST', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json;charset=utf-8', + }, + credentials: 'include', + body: null, + }); + }); + + it('should not append query params to POST requests if body is set as data', () => { + const result = buildConfig( + 'POST', + 'https://example.com/api', + { + foo: 'bar', + }, + {}, + ); + + expect(result).toEqual({ + url: 'https://example.com/api', + method: 'POST', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json;charset=utf-8', + }, + body: JSON.stringify({ foo: 'bar' }), + }); + }); + + it('should not append body nor data to GET requests', () => { + const result = buildConfig( + 'GET', + 'https://example.com/api', + { foo: 'bar' }, + { body: { additional: 'info' }, data: { additional: 'info' } }, + ); + + expect(result).toEqual({ + url: 'https://example.com/api?foo=bar', + method: 'GET', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json;charset=utf-8', + }, + }); + }); }); describe('request()', () => { From 2abde220a9c28c1de323b8e75b2b19859534ded3 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 02:41:05 +0200 Subject: [PATCH 15/16] chore: Add compatibility for bundlers to recognize "browser" from package.json --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 38669f7..b50c9b3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "type": "git", "url": "https://github.com/MattCCC/axios-multi-api.git" }, - "main": "dist/browser/index.mjs", + "main": "dist/node/index.js", + "browser": "dist/browser/index.mjs", + "module": "dist/browser/index.mjs", "typings": "dist/index.d.ts", "keywords": [ "axios-api", @@ -40,7 +42,6 @@ "singleQuote": true, "trailingComma": "all" }, - "module": "dist/browser/index.mjs", "size-limit": [ { "path": "dist/browser/index.mjs", From 7ce55c94c0639b18d726cb9885fbf50870aac021 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 23 Aug 2024 02:41:49 +0200 Subject: [PATCH 16/16] chore: Add keywords --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index b50c9b3..6ee7d8a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "axios-api", "axios-api-handler", "axios-multi-api", + "fetchf", + "fetch-wrapper", + "fetch", "api", "api-handler", "browser",