diff --git a/README.md b/README.md index ee0efc3..068528f 100644 --- a/README.md +++ b/README.md @@ -12,66 +12,6 @@ See [docs](/docs) for further instructions on how to use. ## Overview -## HTTP Client - -The library provides methods to implement the client side of HTTP protocols. Public methods available are: - -- `buildClient()`, which returns a [Client](https://undici.nodejs.org/#/docs/api/Client) instance and should be called before any of the following methods with parameters: - - `baseUrl`; - - `clientOptions` – set of [ClientOptions](https://undici.nodejs.org/#/docs/api/Client?id=parameter-clientoptions) (optional). If none are provided, the following default options will be used to instantiate the client: - ```ts - keepAliveMaxTimeout: 300_000, - keepAliveTimeout: 4000, - ``` -- `sendGet()`; -- `sendPost()`; -- `sendPut()`; -- `sendPutBinary()`; -- `sendDelete()`; -- `sendPatch()`. - -All _send_ methods accept a type parameter and the following arguments: - -- `client`, the return value of `buildClient()`; -- `path`; -- `options` – (optional). Possible values are: - - - `headers`; - - `query`, query string params to be embedded in the request URL; - - `timeout`, the timeout after which a request will time out, in milliseconds. Default is 30 seconds. Pass `undefined` if you prefer to have no timeout; - - `throwOnError`;` - - `reqContext`; - - `safeParseJson`, used when the response content-type is `application/json`. If `true`, the response body will be parsed as JSON and a `ResponseError` will be thrown in case of syntax errors. If `false`, errors are not handled; - - `blobResponseBody`, used when the response body should be returned as Blob; - - `requestLabel`, this string will be returned together with any thrown or returned Error to provide additional context about what request was being executed when the error has happened; - - `disableKeepAlive`;` - - `retryConfig`, defined by: - - `maxAttempts`, the maximum number of times a request should be retried; - - `delayBetweenAttemptsInMsecs`; - - `statusCodesToRetry`, the status codes that trigger a retry; - - `retryOnTimeout`; - - `clientOptions`; - - `responseSchema`, used both for inferring the response type of the call, and also (if `validateResponse` is `true`) for validating the response structure; - - `validateResponse`; - - The following options are applied by default: - - ```ts - validateResponse: true, - throwOnError: true, - timeout: 30000, - retryConfig: { - maxAttemps: 1, - delayBetweenAttemptsInMsecs: 0, - statusCodesToRetry: [], - retryOnTimeout: false, - } - ``` - -Additionally, `sendPost()`, `sendPut()`, `sendPutBinary()`, and `sendPatch()` also accept a `body` parameter. - -The response of any _send_ method will be resolved to always have `result` set, but only have `error` set in case something went wrong. See [Either](#either) for more information. - ## Default Logging Configuration The library provides methods to resolve the default logging configuration. Public methods available are: @@ -239,3 +179,11 @@ expect(someEventEmitter.emittedEvents.length).toBe(1) ## Hashing - `HashUtils` - utils for hashing using sha256/sha512 algorithms + +## Checksum + +- `ChecksumUtils` - utils for insecure hashing using the MD5 algorithm + +## Streams + +- `StreamUtils` - utils for temporary persisting of streams for length calculation and reuse diff --git a/index.ts b/index.ts index 1872695..3cdd74f 100644 --- a/index.ts +++ b/index.ts @@ -1,20 +1,3 @@ -export { - sendPut, - sendPutBinary, - sendDelete, - sendPatch, - sendGet, - sendPost, - sendPostBinary, - httpClient, - buildClient, - type RequestOptions, - type Response, - type HttpRequestContext, - type ResponseSchema, - JSON_HEADERS, -} from './src/http/httpClient' - export { PublicNonRecoverableError, type PublicNonRecoverableErrorParams, @@ -25,8 +8,7 @@ export { type ErrorDetails, type InternalErrorParams, } from './src/errors/InternalError' -export { ResponseStatusError } from './src/errors/ResponseStatusError' -export { isResponseStatusError, isEntityGoneError } from './src/errors/errorTypeGuards' +export { isEntityGoneError } from './src/errors/errorTypeGuards' export { ConfigScope } from './src/config/ConfigScope' export { ensureClosingSlashTransformer } from './src/config/configTransformers' @@ -110,7 +92,16 @@ export { export { waitAndRetry } from './src/utils/waitUtils' -export * from './src/observability/observabilityTypes' +export type { TransactionObservabilityManager } from './src/observability/observabilityTypes' -export * from './src/utils/checksumUtils' -export * from './src/utils/streamUtils' +export { + generateChecksumForReadable, + generateChecksumForObject, + generateChecksumForBufferOrString, +} from './src/utils/checksumUtils' +export { FsReadableProvider } from './src/utils/streamUtils' +export type { + PersistToFsOptions, + ReadableProvider, + FsReadableProviderOptions, +} from './src/utils/streamUtils' diff --git a/package.json b/package.json index 3aab3cf..e7f1aa4 100644 --- a/package.json +++ b/package.json @@ -37,21 +37,19 @@ "dot-prop": "6.0.1", "pino": "^9.1.0", "tslib": "^2.6.2", - "undici": "^6.18.2", - "undici-retry": "^5.0.3", "zod": "^3.23.8" }, "devDependencies": { - "@types/node": "^20.12.8", + "@types/node": "^20.14.2", "@types/tmp": "^0.2.6", - "@typescript-eslint/eslint-plugin": "^7.8.0", - "@typescript-eslint/parser": "^7.8.0", + "@typescript-eslint/eslint-plugin": "^7.12.0", + "@typescript-eslint/parser": "^7.12.0", "@vitest/coverage-v8": "1.6.0", "auto-changelog": "^2.4.0", "eslint": "^8.57.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-vitest": "0.4.1", - "prettier": "^3.2.5", + "prettier": "^3.3.1", "tmp": "^0.2.3", "typescript": "^5.4.5", "vitest": "1.6.0" diff --git a/src/errors/ResponseStatusError.ts b/src/errors/ResponseStatusError.ts deleted file mode 100644 index bf185a6..0000000 --- a/src/errors/ResponseStatusError.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { RequestResult } from 'undici-retry' - -import { InternalError } from './InternalError' - -export class ResponseStatusError extends InternalError { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public readonly response: RequestResult - public readonly isResponseStatusError = true - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(requestResult: RequestResult, requestLabel = 'N/A') { - super({ - message: `Response status code ${requestResult.statusCode}`, - details: { - requestLabel, - response: { - statusCode: requestResult.statusCode, - body: requestResult.body, - }, - }, - errorCode: 'REQUEST_ERROR', - }) - this.response = requestResult - this.name = 'ResponseStatusError' - } -} diff --git a/src/errors/errorTypeGuards.spec.ts b/src/errors/errorTypeGuards.spec.ts index 3a4eae6..4f81fe2 100644 --- a/src/errors/errorTypeGuards.spec.ts +++ b/src/errors/errorTypeGuards.spec.ts @@ -1,29 +1,10 @@ -import { describe } from 'vitest' +import { describe, expect, it } from 'vitest' import { InternalError } from './InternalError' -import { ResponseStatusError } from './ResponseStatusError' -import { isEntityGoneError, isResponseStatusError } from './errorTypeGuards' +import { isEntityGoneError } from './errorTypeGuards' import { EntityGoneError, EntityNotFoundError } from './publicErrors' describe('errorTypeGuards', () => { - describe('isResponseStatusError', () => { - it('Returns true for ResponseStatusError', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-explicit-any - const error = new ResponseStatusError({} as any, 'label') - - expect(isResponseStatusError(error)).toBe(true) - }) - - it('Returns false for not a ResponseStatusError', () => { - const error = new InternalError({ - message: 'message', - errorCode: 'CODE', - }) - - expect(isResponseStatusError(error)).toBe(false) - }) - }) - describe('isEntityGoneError', () => { it('Returns true for EntityGoneError', () => { const error = new EntityGoneError({ diff --git a/src/errors/errorTypeGuards.ts b/src/errors/errorTypeGuards.ts index 51c9351..cd93c93 100644 --- a/src/errors/errorTypeGuards.ts +++ b/src/errors/errorTypeGuards.ts @@ -1,12 +1,7 @@ import { isPublicNonRecoverableError } from '../utils/typeUtils' -import type { ResponseStatusError } from './ResponseStatusError' import type { EntityGoneError } from './publicErrors' -export function isResponseStatusError(entity: unknown): entity is ResponseStatusError { - return 'isResponseStatusError' in (entity as ResponseStatusError) -} - export function isEntityGoneError(entity: unknown): entity is EntityGoneError { return isPublicNonRecoverableError(entity) && entity.httpStatusCode === 410 } diff --git a/src/http/httpClient.spec.ts b/src/http/httpClient.spec.ts deleted file mode 100644 index 98ec703..0000000 --- a/src/http/httpClient.spec.ts +++ /dev/null @@ -1,989 +0,0 @@ -import type { Interceptable } from 'undici' -import { Client, MockAgent, setGlobalDispatcher } from 'undici' -import { isInternalRequestError } from 'undici-retry' -import { z } from 'zod' - -import type { HttpRequestContext } from './httpClient' -import { - buildClient, - sendDelete, - sendGet, - sendPatch, - sendPost, - sendPostBinary, - sendPut, - sendPutBinary, -} from './httpClient' -import mockProduct1 from './mock-data/mockProduct1.json' -import mockProductsLimit3 from './mock-data/mockProductsLimit3.json' - -const JSON_HEADERS = { - 'content-type': 'application/json', -} - -const TEXT_HEADERS = { - 'content-type': 'text/plain', -} - -const baseUrl = 'https://fakestoreapi.com' -const reqContext: HttpRequestContext = { - reqId: 'dummyId', -} - -describe('httpClient', () => { - let mockAgent: MockAgent - let client: Client & Interceptable - beforeEach(() => { - mockAgent = new MockAgent() - mockAgent.disableNetConnect() - setGlobalDispatcher(mockAgent) - client = mockAgent.get(baseUrl) as unknown as Client & Interceptable - }) - - describe('buildClient', () => { - it('creates a client', () => { - const client = buildClient(baseUrl) - expect(client).toBeInstanceOf(Client) - }) - }) - - describe('GET', () => { - it('validates response structure with provided schema, throws an error', async () => { - const schema = z.object({ - id: z.string(), - }) - - client - .intercept({ - path: '/products/1', - method: 'GET', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - await expect( - sendGet(client, '/products/1', { - responseSchema: schema, - reqContext, - validateResponse: true, - }), - ).rejects.toThrow(/Expected string, received number/) - }) - - it('validates response structure with provided schema, passes validation', async () => { - const schema = z.object({ - category: z.string(), - description: z.string(), - id: z.number(), - image: z.string(), - price: z.number(), - rating: z.object({ - count: z.number(), - rate: z.number(), - }), - title: z.string(), - }) - - client - .intercept({ - path: '/products/1', - method: 'GET', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - const result = await sendGet(client, '/products/1', { - responseSchema: schema, - validateResponse: true, - }) - - expect(result.result.body).toEqual(mockProduct1) - }) - - it('validates response structure with provided schema, skips validation', async () => { - const schema = z.object({ - id: z.string(), - }) - - client - .intercept({ - path: '/products/1', - method: 'GET', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - const result = await sendGet(client, '/products/1', { - responseSchema: schema, - validateResponse: false, - }) - - expect(result.result.body).toEqual(mockProduct1) - }) - - it('validates response structure with provided schema, no validation specified', async () => { - client - .intercept({ - path: '/products/1', - method: 'GET', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - const result = await sendGet(client, '/products/1', { - validateResponse: true, - }) - - expect(result.result.body).toEqual(mockProduct1) - }) - - it('returns original payload when breaking during parsing and throw on error is true', async () => { - expect.assertions(1) - client - .intercept({ - path: '/products/1', - method: 'GET', - }) - .reply(200, 'this is not a real json', { headers: JSON_HEADERS }) - - try { - await sendGet(client, '/products/1', { - throwOnError: true, - safeParseJson: true, - requestLabel: 'label', - }) - } catch (err) { - // This is needed, because built-in error assertions do not assert nested fields - // eslint-disable-next-line vitest/no-conditional-expect - expect(err).toMatchObject({ - message: 'Error while parsing HTTP JSON response', - errorCode: 'INVALID_HTTP_RESPONSE_JSON', - details: { - rawBody: 'this is not a real json', - requestLabel: 'label', - }, - }) - } - }) - - it('does not throw if broken during parsing but throwOnError is false', async () => { - expect.assertions(1) - client - .intercept({ - path: '/products/1', - method: 'GET', - }) - .reply(200, 'this is not a real json', { headers: JSON_HEADERS }) - - const result = await sendGet(client, '/products/1', { - throwOnError: false, - safeParseJson: true, - requestLabel: 'label', - }) - - expect(result.error).toMatchObject({ - message: 'Error while parsing HTTP JSON response', - errorCode: 'INVALID_HTTP_RESPONSE_JSON', - details: { - rawBody: 'this is not a real json', - requestLabel: 'label', - }, - }) - }) - - it('GET without queryParams', async () => { - client - .intercept({ - path: '/products/1', - method: 'GET', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - const result = await sendGet(client, '/products/1') - - expect(result.result.body).toEqual(mockProduct1) - }) - - it('GET returning text', async () => { - client - .intercept({ - path: '/products/1', - method: 'GET', - }) - .reply(200, 'just text', { - headers: TEXT_HEADERS, - }) - - const result = await sendGet(client, '/products/1') - - expect(result.result.body).toBe('just text') - }) - - it('GET returning text without content type', async () => { - client - .intercept({ - path: '/products/1', - method: 'GET', - }) - .reply(200, 'just text', {}) - - const result = await sendGet(client, '/products/1') - - expect(result.result.body).toBe('just text') - }) - - it('GET with queryParams', async () => { - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'GET', - query, - }) - .reply(200, mockProductsLimit3, { headers: JSON_HEADERS }) - - const result = await sendGet(client, '/products', { - query, - }) - - expect(result.result.body).toEqual(mockProductsLimit3) - }) - - it('Throws an error on internal error', async () => { - expect.assertions(1) - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'GET', - query, - }) - .replyWithError(new Error('connection error')) - - await expect( - sendGet(client, '/products', { - query, - }), - ).rejects.toMatchObject({ - message: 'connection error', - }) - }) - - it('Throws an error with a label on internal error', async () => { - expect.assertions(2) - const query = { - limit: 3, - } - - try { - await sendGet(buildClient('http://127.0.0.1:999'), '/dummy', { - requestLabel: 'label', - throwOnError: true, - query, - }) - } catch (err) { - if (!isInternalRequestError(err)) { - throw new Error('Invalid error type') - } - expect(err.message).toBe('connect ECONNREFUSED 127.0.0.1:999') - expect(err.requestLabel).toBe('label') - } - }) - - it('Returns error response', async () => { - expect.assertions(1) - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'GET', - query, - }) - .reply(400, 'Invalid request') - - await expect( - sendGet(client, '/products', { - query, - requestLabel: 'label', - }), - ).rejects.toMatchObject({ - message: 'Response status code 400', - response: { - body: 'Invalid request', - statusCode: 400, - }, - details: { - requestLabel: 'label', - response: { - body: 'Invalid request', - statusCode: 400, - }, - }, - }) - }) - - it('Works with retry', async () => { - expect.assertions(1) - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'GET', - query, - }) - .reply(500, 'Invalid request') - client - .intercept({ - path: '/products', - method: 'GET', - query, - }) - .reply(200, 'OK') - - const response = await sendGet(client, '/products', { - query, - retryConfig: { - statusCodesToRetry: [500], - retryOnTimeout: false, - delayBetweenAttemptsInMsecs: 0, - maxAttempts: 2, - }, - }) - - expect(response.result.body).toBe('OK') - }) - }) - - describe('DELETE', () => { - it('DELETE without queryParams', async () => { - client - .intercept({ - path: '/products/1', - method: 'DELETE', - }) - .reply(204, undefined, { headers: TEXT_HEADERS }) - - const result = await sendDelete(client, '/products/1', { - reqContext, - }) - - expect(result.result.statusCode).toBe(204) - expect(result.result.body).toBe('') - }) - - it('DELETE with queryParams', async () => { - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'DELETE', - query, - }) - .reply(204, undefined, { headers: TEXT_HEADERS }) - - const result = await sendDelete(client, '/products', { - query, - }) - - expect(result.result.statusCode).toBe(204) - expect(result.result.body).toBe('') - }) - - it('Throws an error on internal error', async () => { - expect.assertions(1) - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'DELETE', - query, - }) - .replyWithError(new Error('connection error')) - - await expect( - sendDelete(client, '/products', { - query, - }), - ).rejects.toMatchObject({ - message: 'connection error', - }) - }) - }) - - describe('POST', () => { - it('validates response structure with provided schema, throws an error', async () => { - const schema = z.object({ - id: z.string(), - }) - - client - .intercept({ - path: '/products/1', - method: 'POST', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - await expect( - sendPost( - client, - '/products/1', - {}, - { - responseSchema: schema, - validateResponse: true, - }, - ), - ).rejects.toThrow(/Expected string, received number/) - }) - - it('validates response structure with provided schema, passes validation', async () => { - const schema = z.object({ - category: z.string(), - description: z.string(), - id: z.number(), - image: z.string(), - price: z.number(), - rating: z.object({ - count: z.number(), - rate: z.number(), - }), - title: z.string(), - }) - - client - .intercept({ - path: '/products/1', - method: 'POST', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - const result = await sendPost( - client, - '/products/1', - {}, - { - responseSchema: schema, - validateResponse: true, - reqContext, - }, - ) - - expect(result.result.body).toEqual(mockProduct1) - }) - - it('validates response structure with provided schema, skips validation', async () => { - const schema = z.object({ - id: z.string(), - }) - - client - .intercept({ - path: '/products/1', - method: 'POST', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - const result = await sendPost( - client, - '/products/1', - {}, - { - responseSchema: schema, - validateResponse: false, - }, - ) - - expect(result.result.body).toEqual(mockProduct1) - }) - - it('POST without queryParams', async () => { - client - .intercept({ - path: '/products', - method: 'POST', - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPost(client, '/products', mockProduct1) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('POST without body', async () => { - client - .intercept({ - path: '/products', - method: 'POST', - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPost(client, '/products', undefined) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('POST with queryParams', async () => { - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'POST', - query, - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPost(client, '/products', mockProduct1, { - query, - }) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('POST that returns 400 throws an error', async () => { - client - .intercept({ - path: '/products', - method: 'POST', - }) - .reply(400, { errorCode: 'err' }, { headers: JSON_HEADERS }) - - await expect(sendPost(client, '/products', mockProduct1)).rejects.toThrow( - 'Response status code 400', - ) - }) - - it('Throws an error on internal error', async () => { - expect.assertions(1) - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'POST', - query, - }) - .replyWithError(new Error('connection error')) - - await expect( - sendPost(client, '/products', undefined, { - query, - }), - ).rejects.toMatchObject({ - message: 'connection error', - }) - }) - }) - - describe('POST binary', () => { - it('validates response structure with provided schema, throws an error', async () => { - const schema = z.object({ - id: z.string(), - }) - - client - .intercept({ - path: '/products/1', - method: 'POST', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - await expect( - sendPostBinary(client, '/products/1', Buffer.from(JSON.stringify({})), { - responseSchema: schema, - validateResponse: true, - }), - ).rejects.toThrow(/Expected string, received number/) - }) - - it('validates response structure with provided schema, passes validation', async () => { - const schema = z.object({ - category: z.string(), - description: z.string(), - id: z.number(), - image: z.string(), - price: z.number(), - rating: z.object({ - count: z.number(), - rate: z.number(), - }), - title: z.string(), - }) - - client - .intercept({ - path: '/products/1', - method: 'POST', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - const result = await sendPostBinary(client, '/products/1', Buffer.from(JSON.stringify({})), { - responseSchema: schema, - validateResponse: true, - reqContext, - }) - - expect(result.result.body).toEqual(mockProduct1) - }) - - it('validates response structure with provided schema, skips validation', async () => { - const schema = z.object({ - id: z.string(), - }) - - client - .intercept({ - path: '/products/1', - method: 'POST', - }) - .reply(200, mockProduct1, { headers: JSON_HEADERS }) - - const result = await sendPostBinary(client, '/products/1', Buffer.from(JSON.stringify({})), { - responseSchema: schema, - validateResponse: false, - }) - - expect(result.result.body).toEqual(mockProduct1) - }) - - it('POST without queryParams', async () => { - client - .intercept({ - path: '/products', - method: 'POST', - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPostBinary( - client, - '/products', - Buffer.from(JSON.stringify(mockProduct1)), - ) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('POST with queryParams', async () => { - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'POST', - query, - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPostBinary( - client, - '/products', - Buffer.from(JSON.stringify(mockProduct1)), - { - query, - }, - ) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('POST that returns 400 throws an error', async () => { - client - .intercept({ - path: '/products', - method: 'POST', - }) - .reply(400, { errorCode: 'err' }, { headers: JSON_HEADERS }) - - await expect( - sendPostBinary(client, '/products', Buffer.from(JSON.stringify(mockProduct1))), - ).rejects.toThrow('Response status code 400') - }) - - it('Throws an error on internal error', async () => { - expect.assertions(1) - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'POST', - query, - }) - .replyWithError(new Error('connection error')) - - await expect( - sendPostBinary(client, '/products', Buffer.from(JSON.stringify({})), { - query, - }), - ).rejects.toMatchObject({ - message: 'connection error', - }) - }) - }) - - describe('PUT', () => { - it('PUT without queryParams', async () => { - client - .intercept({ - path: '/products/1', - method: 'PUT', - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPut(client, '/products/1', mockProduct1, { - reqContext, - }) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('PUT without body', async () => { - client - .intercept({ - path: '/products/1', - method: 'PUT', - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPut(client, '/products/1', undefined) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('PUT with queryParams', async () => { - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products/1', - method: 'PUT', - query, - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPut(client, '/products/1', mockProduct1, { - query, - }) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('PUT that returns 400 throws an error', async () => { - client - .intercept({ - path: '/products/1', - method: 'PUT', - }) - .reply(400, { errorCode: 'err' }, { headers: JSON_HEADERS }) - - await expect(sendPut(client, '/products/1', mockProduct1)).rejects.toThrow( - 'Response status code 400', - ) - }) - - it('Throws an error on internal error', async () => { - expect.assertions(1) - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'PUT', - query, - }) - .replyWithError(new Error('connection error')) - - await expect( - sendPut(client, '/products', undefined, { - query, - }), - ).rejects.toMatchObject({ - message: 'connection error', - }) - }) - }) - - describe('PUT binary', () => { - it('PUT without queryParams', async () => { - client - .intercept({ - path: '/products/1', - method: 'PUT', - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPutBinary(client, '/products/1', Buffer.from('text'), { - reqContext, - }) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('PUT with queryParams', async () => { - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products/1', - method: 'PUT', - query, - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPutBinary(client, '/products/1', Buffer.from('text'), { - query, - }) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('PUT that returns 400 throws an error', async () => { - client - .intercept({ - path: '/products/1', - method: 'PUT', - }) - .reply(400, { errorCode: 'err' }, { headers: JSON_HEADERS }) - - await expect(sendPutBinary(client, '/products/1', Buffer.from('text'))).rejects.toThrow( - 'Response status code 400', - ) - }) - - it('Throws an error on internal error', async () => { - expect.assertions(1) - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'PUT', - query, - }) - .replyWithError(new Error('connection error')) - - await expect( - sendPutBinary(client, '/products', null, { - query, - }), - ).rejects.toMatchObject({ - message: 'connection error', - }) - }) - }) - - describe('PATCH', () => { - it('PATCH without queryParams', async () => { - client - .intercept({ - path: '/products/1', - method: 'PATCH', - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPatch(client, '/products/1', mockProduct1) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('PATCH without body', async () => { - client - .intercept({ - path: '/products/1', - method: 'PATCH', - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPatch(client, '/products/1', undefined) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('PATCH with queryParams', async () => { - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products/1', - method: 'PATCH', - query, - }) - .reply(200, { id: 21 }, { headers: JSON_HEADERS }) - - const result = await sendPatch(client, '/products/1', mockProduct1, { - query, - reqContext, - }) - - expect(result.result.body).toEqual({ id: 21 }) - }) - - it('PATCH that returns 400 throws an error', async () => { - client - .intercept({ - path: '/products/1', - method: 'PATCH', - }) - .reply(400, { errorCode: 'err' }, { headers: JSON_HEADERS }) - - await expect(sendPatch(client, '/products/1', mockProduct1)).rejects.toThrow( - 'Response status code 400', - ) - }) - - it('Throws an error on internal error', async () => { - expect.assertions(1) - const query = { - limit: 3, - } - - client - .intercept({ - path: '/products', - method: 'PATCH', - query, - }) - .replyWithError(new Error('connection error')) - - await expect( - sendPatch(client, '/products', undefined, { - query, - }), - ).rejects.toMatchObject({ - message: 'connection error', - }) - }) - }) -}) diff --git a/src/http/httpClient.ts b/src/http/httpClient.ts deleted file mode 100644 index 681a3c0..0000000 --- a/src/http/httpClient.ts +++ /dev/null @@ -1,360 +0,0 @@ -import type { Readable } from 'stream' - -import { Client } from 'undici' -import type { FormData } from 'undici' -import { isRequestResult, NO_RETRY_CONFIG, sendWithRetry } from 'undici-retry' -import type { RequestResult, RequestParams, RetryConfig, InternalRequestError } from 'undici-retry' - -import type { MayOmit } from '../common/may-omit' -import { ResponseStatusError } from '../errors/ResponseStatusError' -import type { DefiniteEither, Either } from '../errors/either' -import { copyWithoutUndefined } from '../utils/objectUtils' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type RecordObject = Record - -export type HttpRequestContext = { - reqId: string -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ResponseSchema = { - parse(data: unknown): Output -} - -export type RequestOptions = { - headers?: RecordObject - query?: RecordObject - timeout: number | undefined - throwOnError?: boolean - reqContext?: HttpRequestContext - - safeParseJson?: boolean - blobResponseBody?: boolean - requestLabel: string - - disableKeepAlive?: boolean - retryConfig?: RetryConfig - clientOptions?: Client.Options - responseSchema?: ResponseSchema - validateResponse: boolean -} - -const DEFAULT_OPTIONS = { - validateResponse: true, - throwOnError: true, - timeout: 30000, -} satisfies MayOmit, 'requestLabel'> - -const defaultClientOptions: Partial = { - keepAliveMaxTimeout: 300_000, - keepAliveTimeout: 4000, -} - -export type Response = { - body: T - headers: RecordObject - statusCode: number -} - -export async function sendGet( - client: Client, - path: string, - options: Partial> = {}, -): Promise, RequestResult>> { - const result = await sendWithRetry( - client, - { - ...DEFAULT_OPTIONS, - path: path, - method: 'GET', - query: options.query, - headers: copyWithoutUndefined({ - 'x-request-id': options.reqContext?.reqId, - ...options.headers, - }), - reset: options.disableKeepAlive ?? false, - bodyTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - headersTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - throwOnError: false, - }, - resolveRetryConfig(options), - resolveRequestConfig(options), - ) - - return resolveResult( - result, - options.throwOnError ?? DEFAULT_OPTIONS.throwOnError, - options.validateResponse ?? DEFAULT_OPTIONS.validateResponse, - options.responseSchema, - options.requestLabel, - ) -} - -export async function sendDelete( - client: Client, - path: string, - options: Partial> = {}, -): Promise, RequestResult>> { - const result = await sendWithRetry( - client, - { - ...DEFAULT_OPTIONS, - path, - method: 'DELETE', - query: options.query, - headers: copyWithoutUndefined({ - 'x-request-id': options.reqContext?.reqId, - ...options.headers, - }), - reset: options.disableKeepAlive ?? false, - bodyTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - headersTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - throwOnError: false, - }, - resolveRetryConfig(options), - resolveRequestConfig(options), - ) - - return resolveResult( - result, - options.throwOnError ?? DEFAULT_OPTIONS.throwOnError, - options.validateResponse ?? DEFAULT_OPTIONS.validateResponse, - options.responseSchema, - options.requestLabel, - ) -} - -export async function sendPost( - client: Client, - path: string, - body: RecordObject | undefined, - options: Partial> = {}, -): Promise, RequestResult>> { - const result = await sendWithRetry( - client, - { - ...DEFAULT_OPTIONS, - path: path, - method: 'POST', - body: body ? JSON.stringify(body) : undefined, - query: options.query, - headers: copyWithoutUndefined({ - 'x-request-id': options.reqContext?.reqId, - ...options.headers, - }), - reset: options.disableKeepAlive ?? false, - bodyTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - headersTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - throwOnError: false, - }, - resolveRetryConfig(options), - resolveRequestConfig(options), - ) - - return resolveResult( - result, - options.throwOnError ?? DEFAULT_OPTIONS.throwOnError, - options.validateResponse ?? DEFAULT_OPTIONS.validateResponse, - options.responseSchema, - options.requestLabel, - ) -} - -export async function sendPostBinary( - client: Client, - path: string, - body: Buffer | Uint8Array | Readable | FormData | null, - options: Partial> = {}, -): Promise, RequestResult>> { - const result = await sendWithRetry( - client, - { - ...DEFAULT_OPTIONS, - path: path, - method: 'POST', - body, - query: options.query, - headers: copyWithoutUndefined({ - 'x-request-id': options.reqContext?.reqId, - ...options.headers, - }), - reset: options.disableKeepAlive ?? false, - bodyTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - headersTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - throwOnError: false, - }, - resolveRetryConfig(options), - resolveRequestConfig(options), - ) - - return resolveResult( - result, - options.throwOnError ?? DEFAULT_OPTIONS.throwOnError, - options.validateResponse ?? DEFAULT_OPTIONS.validateResponse, - options.responseSchema, - options.requestLabel, - ) -} - -export async function sendPut( - client: Client, - path: string, - body: RecordObject | undefined, - options: Partial> = {}, -): Promise, RequestResult>> { - const result = await sendWithRetry( - client, - { - ...DEFAULT_OPTIONS, - path: path, - method: 'PUT', - body: body ? JSON.stringify(body) : undefined, - query: options.query, - headers: copyWithoutUndefined({ - 'x-request-id': options.reqContext?.reqId, - ...options.headers, - }), - reset: options.disableKeepAlive ?? false, - bodyTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - headersTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - throwOnError: false, - }, - resolveRetryConfig(options), - resolveRequestConfig(options), - ) - - return resolveResult( - result, - options.throwOnError ?? DEFAULT_OPTIONS.throwOnError, - options.validateResponse ?? DEFAULT_OPTIONS.validateResponse, - options.responseSchema, - options.requestLabel, - ) -} - -export async function sendPutBinary( - client: Client, - path: string, - body: Buffer | Uint8Array | Readable | FormData | null, - options: Partial> = {}, -): Promise, RequestResult>> { - const result = await sendWithRetry( - client, - { - ...DEFAULT_OPTIONS, - path: path, - method: 'PUT', - body, - query: options.query, - headers: copyWithoutUndefined({ - 'x-request-id': options.reqContext?.reqId, - ...options.headers, - }), - reset: options.disableKeepAlive ?? false, - bodyTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - headersTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - throwOnError: false, - }, - resolveRetryConfig(options), - resolveRequestConfig(options), - ) - - return resolveResult( - result, - options.throwOnError ?? DEFAULT_OPTIONS.throwOnError, - options.validateResponse ?? DEFAULT_OPTIONS.validateResponse, - options.responseSchema, - options.requestLabel, - ) -} - -export async function sendPatch( - client: Client, - path: string, - body: RecordObject | undefined, - options: Partial> = {}, -): Promise, RequestResult>> { - const result = await sendWithRetry( - client, - { - ...DEFAULT_OPTIONS, - path: path, - method: 'PATCH', - body: body ? JSON.stringify(body) : undefined, - query: options.query, - headers: copyWithoutUndefined({ - 'x-request-id': options.reqContext?.reqId, - ...options.headers, - }), - reset: options.disableKeepAlive ?? false, - bodyTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - headersTimeout: Object.hasOwn(options, 'timeout') ? options.timeout : DEFAULT_OPTIONS.timeout, - throwOnError: false, - }, - resolveRetryConfig(options), - resolveRequestConfig(options), - ) - - return resolveResult( - result, - options.throwOnError ?? DEFAULT_OPTIONS.throwOnError, - options.validateResponse ?? DEFAULT_OPTIONS.validateResponse, - options.responseSchema, - options.requestLabel, - ) -} - -function resolveRequestConfig(options: Partial>): RequestParams { - return { - safeParseJson: options.safeParseJson ?? false, - blobBody: options.blobResponseBody ?? false, - throwOnInternalError: false, - requestLabel: options.requestLabel, - } -} - -function resolveRetryConfig(options: Partial>): RetryConfig { - return options.retryConfig ?? NO_RETRY_CONFIG -} - -export function buildClient(baseUrl: string, clientOptions?: Client.Options) { - const newClient = new Client(baseUrl, { - ...defaultClientOptions, - ...clientOptions, - }) - return newClient -} - -function resolveResult( - requestResult: Either | InternalRequestError, RequestResult>, - throwOnError: boolean, - validateResponse: boolean, - validationSchema?: ResponseSchema, - requestLabel?: string, -): DefiniteEither, RequestResult> { - // Throw response error - if (requestResult.error && throwOnError) { - if (isRequestResult(requestResult.error)) { - throw new ResponseStatusError(requestResult.error, requestLabel) - } - throw requestResult.error - } - if (requestResult.result && validateResponse && validationSchema) { - requestResult.result.body = validationSchema.parse(requestResult.result.body) - } - - return requestResult as DefiniteEither, RequestResult> -} - -export const httpClient = { - get: sendGet, - post: sendPost, - put: sendPut, - patch: sendPatch, - del: sendDelete, -} - -export const JSON_HEADERS = { - 'Content-Type': 'application/json', -} diff --git a/src/http/mock-data/mockProduct1.json b/src/http/mock-data/mockProduct1.json deleted file mode 100644 index 792e9df..0000000 --- a/src/http/mock-data/mockProduct1.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "category": "men's clothing", - "description": "Your perfect pack for everyday use and walks in the forest. Stash your laptop (up to 15 inches) in the padded sleeve, your everyday", - "id": 1, - "image": "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg", - "price": 109.95, - "rating": { - "count": 120, - "rate": 3.9 - }, - "title": "Fjallraven - Foldsack No. 1 Backpack, Fits 14 Laptops" -} diff --git a/src/http/mock-data/mockProductsLimit3.json b/src/http/mock-data/mockProductsLimit3.json deleted file mode 100644 index b2b1abc..0000000 --- a/src/http/mock-data/mockProductsLimit3.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "category": "men's clothing", - "description": "Your perfect pack for everyday use and walks in the forest. Stash your laptop (up to 15 inches) in the padded sleeve, your everyday", - "id": 1, - "image": "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg", - "price": 109.95, - "rating": { - "count": 120, - "rate": 3.9 - }, - "title": "Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops" - }, - { - "category": "men's clothing", - "description": "Slim-fitting style, contrast raglan long sleeve, three-button henley placket, light weight & soft fabric for breathable and comfortable wearing. And Solid stitched shirts with round neck made for durability and a great fit for casual fashion wear and diehard baseball fans. The Henley style round neckline includes a three-button placket.", - "id": 2, - "image": "https://fakestoreapi.com/img/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg", - "price": 22.3, - "rating": { - "count": 259, - "rate": 4.1 - }, - "title": "Mens Casual Premium Slim Fit T-Shirts " - }, - { - "category": "men's clothing", - "description": "great outerwear jackets for Spring/Autumn/Winter, suitable for many occasions, such as working, hiking, camping, mountain/rock climbing, cycling, traveling or other outdoors. Good gift choice for you or your family member. A warm hearted love to Father, husband or son in this thanksgiving or Christmas Day.", - "id": 3, - "image": "https://fakestoreapi.com/img/71li-ujtlUL._AC_UX679_.jpg", - "price": 55.99, - "rating": { - "count": 500, - "rate": 4.7 - }, - "title": "Mens Cotton Jacket" - } -] diff --git a/src/utils/streamUtils.ts b/src/utils/streamUtils.ts index 2aac14b..4551e77 100644 --- a/src/utils/streamUtils.ts +++ b/src/utils/streamUtils.ts @@ -94,6 +94,10 @@ export class FsReadableProvider implements ReadableProvider { } } +/** + * Consumes the readable in order to calculate its length in bytes + * @param readable + */ export function getReadableContentLength(readable: Readable): Promise { return new Promise((resolve, reject) => { let size = 0