diff --git a/index.ts b/index.ts index 9f9c2d9..05f26cd 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,6 @@ export { sendPut, + sendPutBinary, sendDelete, sendPatch, sendGet, @@ -11,4 +12,5 @@ export type { DeleteRequestOptions, RequestOptions, Response, + HttpRequestContext, } from './src/http/httpClient' diff --git a/jest.config.json b/jest.config.json index d435163..1f152a3 100644 --- a/jest.config.json +++ b/jest.config.json @@ -3,15 +3,10 @@ "testMatch": ["/src/**/*.(spec|test).ts", "/test/**/*.(spec|test).ts"], "transform": { - "^.+\\.ts$": "ts-jest" + "^.+\\.ts$": ["ts-jest", { "diagnostics": false }] }, "testEnvironment": "node", "reporters": ["default"], - "globals": { - "ts-jest": { - "diagnostics": false - } - }, "collectCoverageFrom": ["./src/**/*.ts"], "coveragePathIgnorePatterns": ["/node_modules/", "/coverage/"], "coverageThreshold": { diff --git a/package.json b/package.json index 49cd895..dc68f4e 100644 --- a/package.json +++ b/package.json @@ -31,22 +31,22 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "undici": "^5.10.0" + "undici": "^5.13.0" }, "devDependencies": { - "@types/jest": "^28.1.8", - "@types/node": "^18.7.13", - "@typescript-eslint/eslint-plugin": "^5.35.1", - "@typescript-eslint/parser": "^5.35.1", + "@types/jest": "^29.2.3", + "@types/node": "^18.11.9", + "@typescript-eslint/eslint-plugin": "^5.44.0", + "@typescript-eslint/parser": "^5.44.0", "auto-changelog": "^2.4.0", - "eslint": "^8.23.0", + "eslint": "^8.28.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "^26.8.7", + "eslint-plugin-jest": "^27.1.6", "eslint-plugin-prettier": "^4.2.1", - "jest": "^28.1.3", - "prettier": "^2.7.1", - "ts-jest": "^28.0.8", - "typescript": "4.8.2" + "jest": "^29.3.1", + "prettier": "^2.8.0", + "ts-jest": "^29.0.3", + "typescript": "4.9.3" } } diff --git a/src/http/httpClient.spec.ts b/src/http/httpClient.spec.ts index 61fd88e..9a03104 100644 --- a/src/http/httpClient.spec.ts +++ b/src/http/httpClient.spec.ts @@ -1,6 +1,6 @@ import { MockAgent, setGlobalDispatcher } from 'undici' -import { sendDelete, sendGet, sendPatch, sendPost, sendPut } from './httpClient' +import { sendDelete, sendGet, sendPatch, sendPost, sendPut, sendPutBinary } from './httpClient' import mockProduct1 from './mock-data/mockProduct1.json' import mockProductsLimit3 from './mock-data/mockProductsLimit3.json' @@ -8,6 +8,10 @@ const JSON_HEADERS = { 'content-type': 'application/json', } +const TEXT_HEADERS = { + 'content-type': 'text/plain', +} + describe('httpClient', () => { let mockAgent: MockAgent beforeEach(() => { @@ -39,9 +43,7 @@ describe('httpClient', () => { method: 'GET', }) .reply(200, 'just text', { - headers: { - 'content-type': 'text/plain', - }, + headers: TEXT_HEADERS, }) const result = await sendGet('https://fakestoreapi.com', 'products/1') @@ -79,7 +81,7 @@ describe('httpClient', () => { path: '/products/1', method: 'DELETE', }) - .reply(204) + .reply(204, undefined, { headers: TEXT_HEADERS }) const result = await sendDelete('https://fakestoreapi.com', 'products/1') @@ -100,7 +102,7 @@ describe('httpClient', () => { method: 'DELETE', query, }) - .reply(204) + .reply(204, undefined, { headers: TEXT_HEADERS }) const result = await sendDelete('https://fakestoreapi.com', 'products', { query, @@ -135,7 +137,7 @@ describe('httpClient', () => { }) .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - const result = await sendPost('https://fakestoreapi.com', 'products') + const result = await sendPost('https://fakestoreapi.com', 'products', undefined) expect(result.body).toEqual({ id: 21 }) }) @@ -200,7 +202,7 @@ describe('httpClient', () => { }) .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - const result = await sendPut('https://fakestoreapi.com', 'products/1') + const result = await sendPut('https://fakestoreapi.com', 'products/1', undefined) expect(result.body).toEqual({ id: 21 }) }) @@ -241,6 +243,66 @@ describe('httpClient', () => { }) }) + describe('PUT binary', () => { + it('PUT without queryParams', async () => { + const client = mockAgent.get('https://fakestoreapi.com') + client + .intercept({ + path: '/products/1', + method: 'PUT', + }) + .reply(200, { id: 21 }, { headers: JSON_HEADERS }) + + const result = await sendPutBinary( + 'https://fakestoreapi.com', + 'products/1', + Buffer.from('text'), + ) + + expect(result.body).toEqual({ id: 21 }) + }) + + it('PUT with queryParams', async () => { + const client = mockAgent.get('https://fakestoreapi.com') + const query = { + limit: 3, + } + + client + .intercept({ + path: '/products/1', + method: 'PUT', + query, + }) + .reply(200, { id: 21 }, { headers: JSON_HEADERS }) + + const result = await sendPutBinary( + 'https://fakestoreapi.com', + 'products/1', + Buffer.from('text'), + { + query, + }, + ) + + expect(result.body).toEqual({ id: 21 }) + }) + + it('PUT that returns 400 throws an error', async () => { + const client = mockAgent.get('https://fakestoreapi.com') + client + .intercept({ + path: '/products/1', + method: 'PUT', + }) + .reply(400, { errorCode: 'err' }, { headers: JSON_HEADERS }) + + await expect( + sendPutBinary('https://fakestoreapi.com', 'products/1', Buffer.from('text')), + ).rejects.toThrow('Response status code 400: Bad Request') + }) + }) + describe('PATCH', () => { it('PATCH without queryParams', async () => { const client = mockAgent.get('https://fakestoreapi.com') @@ -265,7 +327,7 @@ describe('httpClient', () => { }) .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - const result = await sendPatch('https://fakestoreapi.com', 'products/1') + const result = await sendPatch('https://fakestoreapi.com', 'products/1', undefined) expect(result.body).toEqual({ id: 21 }) }) diff --git a/src/http/httpClient.ts b/src/http/httpClient.ts index ffea2a5..b9c4222 100644 --- a/src/http/httpClient.ts +++ b/src/http/httpClient.ts @@ -1,14 +1,21 @@ -import type { Dispatcher } from 'undici' +import type { Readable } from 'stream' + import { request } from 'undici' +import type { Dispatcher, FormData } from 'undici' // eslint-disable-next-line @typescript-eslint/no-explicit-any type RecordObject = Record +export type HttpRequestContext = { + reqId: string +} + export type GetRequestOptions = { headers?: RecordObject query?: RecordObject timeout?: number throwOnError?: boolean + reqContext?: HttpRequestContext } export type DeleteRequestOptions = GetRequestOptions @@ -18,6 +25,7 @@ export type RequestOptions = { query?: RecordObject timeout?: number throwOnError?: boolean + reqContext?: HttpRequestContext } const defaultOptions: GetRequestOptions = { @@ -40,7 +48,10 @@ export async function sendGet( const response = await request(url, { method: 'GET', query: options.query, - headers: options.headers, + headers: { + 'x-request-id': options.reqContext?.reqId, + ...options.headers, + }, bodyTimeout: options.timeout, throwOnError: options.throwOnError, }) @@ -65,7 +76,10 @@ export async function sendDelete( const response = await request(url, { method: 'DELETE', query: options.query, - headers: options.headers, + headers: { + 'x-request-id': options.reqContext?.reqId, + ...options.headers, + }, bodyTimeout: options.timeout, throwOnError: options.throwOnError, }) @@ -84,7 +98,7 @@ export async function sendDelete( export async function sendPost( baseUrl: string, path: string, - body?: RecordObject | undefined, + body: RecordObject | undefined, options: RequestOptions = defaultOptions, ): Promise> { const url = resolveUrl(baseUrl, path) @@ -92,7 +106,10 @@ export async function sendPost( method: 'POST', body: body ? JSON.stringify(body) : undefined, query: options.query, - headers: options.headers, + headers: { + 'x-request-id': options.reqContext?.reqId, + ...options.headers, + }, bodyTimeout: options.timeout, throwOnError: options.throwOnError, }) @@ -111,7 +128,7 @@ export async function sendPost( export async function sendPut( baseUrl: string, path: string, - body?: RecordObject | undefined, + body: RecordObject | undefined, options: RequestOptions = defaultOptions, ): Promise> { const url = resolveUrl(baseUrl, path) @@ -119,7 +136,40 @@ export async function sendPut( method: 'PUT', body: body ? JSON.stringify(body) : undefined, query: options.query, - headers: options.headers, + headers: { + 'x-request-id': options.reqContext?.reqId, + ...options.headers, + }, + bodyTimeout: options.timeout, + throwOnError: options.throwOnError, + }) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const resolvedBody = await resolveBody(response) + + return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + body: resolvedBody, + headers: response.headers, + statusCode: response.statusCode, + } +} + +export async function sendPutBinary( + baseUrl: string, + path: string, + body: Buffer | Uint8Array | Readable | null | FormData, + options: RequestOptions = defaultOptions, +): Promise> { + const url = resolveUrl(baseUrl, path) + const response = await request(url, { + method: 'PUT', + body, + query: options.query, + headers: { + 'x-request-id': options.reqContext?.reqId, + ...options.headers, + }, bodyTimeout: options.timeout, throwOnError: options.throwOnError, }) @@ -138,7 +188,7 @@ export async function sendPut( export async function sendPatch( baseUrl: string, path: string, - body?: RecordObject | undefined, + body: RecordObject | undefined, options: RequestOptions = defaultOptions, ): Promise> { const url = resolveUrl(baseUrl, path) @@ -146,7 +196,10 @@ export async function sendPatch( method: 'PATCH', body: body ? JSON.stringify(body) : undefined, query: options.query, - headers: options.headers, + headers: { + 'x-request-id': options.reqContext?.reqId, + ...options.headers, + }, bodyTimeout: options.timeout, throwOnError: options.throwOnError, }) @@ -165,7 +218,7 @@ export async function sendPatch( async function resolveBody(response: Dispatcher.ResponseData) { const contentType = response.headers['content-type'] // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return contentType?.startsWith('application/json') + return !contentType || contentType?.startsWith('application/json') ? await response.body.json() : await response.body.text() }