diff --git a/src/constants.ts b/src/constants.ts index 59c1853..bd4d045 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,7 @@ export const APPLICATION_CONTENT_TYPE = 'application/'; export const APPLICATION_JSON = APPLICATION_CONTENT_TYPE + 'json'; +export const CHARSET_UTF_8 = 'charset=utf-8'; export const CONTENT_TYPE = 'Content-Type'; export const UNDEFINED = 'undefined'; diff --git a/src/request-handler.ts b/src/request-handler.ts index d9ada6b..59f5c53 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -12,6 +12,7 @@ import type { FetcherConfig, FetcherInstance, Logger, + HeadersObject, } from './types/request-handler'; import type { BodyPayload, @@ -37,6 +38,7 @@ import { ABORT_ERROR, APPLICATION_JSON, CANCELLED_ERROR, + CHARSET_UTF_8, CONTENT_TYPE, GET, HEAD, @@ -56,7 +58,6 @@ const defaultConfig: RequestHandlerConfig = { headers: { Accept: APPLICATION_JSON + ', text/plain, */*', 'Accept-Encoding': 'gzip, deflate, br', - [CONTENT_TYPE]: APPLICATION_JSON + ';charset=utf-8', }, retry: { delay: 1000, @@ -165,6 +166,39 @@ export function createRequestHandler( } }; + /** + * Sets the Content-Type header to 'application/json;charset=utf-8' if needed based on the method and body. + * + * @param headers - The headers object where Content-Type will be set. + * @param method - The HTTP method (e.g., GET, POST, PUT, DELETE). + * @param body - Optional request body to determine if Content-Type is needed. + */ + const setContentTypeIfNeeded = ( + headers: HeadersInit, + method: string, + body?: unknown, + ): void => { + if (!body && ['PUT', 'DELETE'].includes(method)) { + return; + } else { + const contentTypeValue = APPLICATION_JSON + ';' + CHARSET_UTF_8; + + if (headers instanceof Headers) { + if (!headers.has(CONTENT_TYPE)) { + headers.set(CONTENT_TYPE, contentTypeValue); + } + } else if ( + typeof headers === OBJECT && + !Array.isArray(headers) && + !headers[CONTENT_TYPE] + ) { + headers[CONTENT_TYPE] = contentTypeValue; + } + } + + return; + }; + /** * Build request configuration * @@ -202,6 +236,10 @@ export function createRequestHandler( body = explicitBodyData; } + const headers = getConfig(requestConfig, 'headers'); + + setContentTypeIfNeeded(headers, method, body); + // Native fetch compatible settings const isWithCredentials = getConfig( requestConfig, @@ -236,7 +274,7 @@ export function createRequestHandler( credentials, body, method, - + headers, url: baseURL + urlPath, }; }; diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index b8c2541..436db2c 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -8,7 +8,13 @@ import type { RequestHandlerReturnType, } from '../src/types/request-handler'; import { fetchf } from '../src'; -import { ABORT_ERROR } from '../src/constants'; +import { + ABORT_ERROR, + APPLICATION_JSON, + CHARSET_UTF_8, + CONTENT_TYPE, + GET, +} from '../src/constants'; import { ResponseErr } from '../src/response-error'; jest.mock('../src/utils', () => { @@ -26,6 +32,7 @@ const fetcher = { describe('Request Handler', () => { const apiUrl = 'http://example.com/api/'; + const contentTypeValue = APPLICATION_JSON + ';' + CHARSET_UTF_8; const responseMock = { data: { test: 'data', @@ -64,11 +71,13 @@ describe('Request Handler', () => { const headers = { Accept: 'application/json, text/plain, */*', 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', + 'Content-Type': contentTypeValue, }; beforeAll(() => { - requestHandler = createRequestHandler({}); + requestHandler = createRequestHandler({ + headers, + }); }); it('should not differ when the same request is made', () => { @@ -270,6 +279,65 @@ describe('Request Handler', () => { }); }); + describe('request() Content-Type', () => { + let requestHandler: RequestHandlerReturnType; + const contentTypeValue = 'application/json;charset=utf-8'; + + beforeEach(() => { + requestHandler = createRequestHandler({}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe.each([ + { method: 'DELETE', body: undefined, expectContentType: false }, + { method: 'PUT', body: undefined, expectContentType: false }, + { method: 'DELETE', body: { foo: 'bar' }, expectContentType: true }, + { method: 'PUT', body: { foo: 'bar' }, expectContentType: true }, + { method: 'POST', body: undefined, expectContentType: true }, + { method: GET, body: undefined, expectContentType: true }, + ])( + '$method request with body: $body', + ({ method, body, expectContentType }) => { + it( + expectContentType + ? 'should set Content-Type when body is provided or method requires it' + : 'should not set Content-Type when no body is provided for DELETE or PUT', + () => { + const result = requestHandler.buildConfig(apiUrl, { method, body }); + if (expectContentType) { + expect(result.headers).toHaveProperty( + CONTENT_TYPE, + contentTypeValue, + ); + } else { + expect(result.headers).not.toHaveProperty(CONTENT_TYPE); + } + }, + ); + }, + ); + + describe.each(['DELETE', 'PUT'])( + '%s method with custom Content-Type', + (method) => { + it(`should keep custom Content-Type for ${method} method`, () => { + const customContentType = 'application/x-www-form-urlencoded'; + const result = requestHandler.buildConfig(apiUrl, { + method, + headers: { 'Content-Type': customContentType }, + }); + expect(result.headers).toHaveProperty( + 'Content-Type', + customContentType, + ); + }); + }, + ); + }); + describe('request()', () => { beforeEach(() => { jest.useFakeTimers();