diff --git a/package.json b/package.json index 3bd41570..f8a49fe8 100644 --- a/package.json +++ b/package.json @@ -123,30 +123,12 @@ "import": "./dist/esm/preset/isomorphic/index.js", "default": "./dist/esm/preset/isomorphic/index.js" }, - "./preset/isomorphic/providers": { - "types": "./dist/types/preset/isomorphic/providers/index.d.ts", - "require": "./dist/cjs/preset/isomorphic/providers/index.js", - "import": "./dist/esm/preset/isomorphic/providers/index.js", - "default": "./dist/esm/preset/isomorphic/providers/index.js" - }, - "./preset/isomorphic/utils": { - "types": "./dist/types/preset/isomorphic/utils/index.d.ts", - "require": "./dist/cjs/preset/isomorphic/utils/index.js", - "import": "./dist/esm/preset/isomorphic/utils/index.js", - "default": "./dist/esm/preset/isomorphic/utils/index.js" - }, "./preset/web": { "types": "./dist/types/preset/web/index.d.ts", "require": "./dist/cjs/preset/web/index.js", "import": "./dist/esm/preset/web/index.js", "default": "./dist/esm/preset/web/index.js" }, - "./preset/web/providers": { - "types": "./dist/types/preset/web/providers/index.d.ts", - "require": "./dist/cjs/preset/web/providers/index.js", - "import": "./dist/esm/preset/web/providers/index.js", - "default": "./dist/esm/preset/web/providers/index.js" - }, "./preset/server": { "types": "./dist/types/preset/server/index.d.ts", "require": "./dist/cjs/preset/server/index.js", @@ -279,18 +261,9 @@ "preset/isomorphic": [ "./dist/types/preset/isomorphic/index.d.ts" ], - "preset/isomorphic/providers": [ - "./dist/types/preset/isomorphic/providers/index.d.ts" - ], - "preset/isomorphic/utils": [ - "./dist/types/preset/isomorphic/utils/index.d.ts" - ], "preset/web": [ "./dist/types/preset/web/index.d.ts" ], - "preset/web/providers": [ - "./dist/types/preset/web/providers/index.d.ts" - ], "preset/server": [ "./dist/types/preset/server/index.d.ts" ], diff --git a/src/config/__test__/base.test.ts b/src/config/__test__/base.test.ts new file mode 100644 index 00000000..41cc6e09 --- /dev/null +++ b/src/config/__test__/base.test.ts @@ -0,0 +1,20 @@ +import { createBaseConfig } from '../base'; +import { createConfigSource } from '../source'; + +describe('createBaseConfig', () => { + it('should works properly', () => { + const source = createConfigSource({ + NODE_ENV: 'tests', + APP_NAME: 'foobar', + APP_VERSION: 'good', + }); + + const config = createBaseConfig(source); + + expect(config).toEqual({ + env: 'tests', + appName: 'foobar', + appVersion: 'good', + }); + }); +}); diff --git a/src/config/__test__/source.test.ts b/src/config/__test__/source.test.ts new file mode 100644 index 00000000..69bdb9b4 --- /dev/null +++ b/src/config/__test__/source.test.ts @@ -0,0 +1,16 @@ +import { createConfigSource } from '../source'; + +describe('createConfigSource', () => { + beforeEach(() => { + (globalThis as any).__ISOMORPH_ENV__ = { + NODE_ENV: 'tests', + }; + }); + + it('should works properly', () => { + const source = createConfigSource({ EXTRA_VAR: '123' }); + + expect(source.get('NODE_ENV')).toBe('tests'); + expect(source.get('EXTRA_VAR')).toBe('123'); + }); +}); diff --git a/src/preset/bun-handler/providers/index.tsx b/src/preset/bun-handler/providers/index.tsx index c4f3e9d0..c60c24e1 100644 --- a/src/preset/bun-handler/providers/index.tsx +++ b/src/preset/bun-handler/providers/index.tsx @@ -15,12 +15,10 @@ import { LogHandlerFactory, } from '../../../http'; import { Fragment } from 'react'; -import { - FetchLogging, - getFetchErrorLogging, - getFetchExtraAborting, - getFetchLogging, -} from '../../isomorphic/utils'; +import { getFetchErrorLogging } from '../../isomorphic/utils/get-fetch-error-logging'; +import { getFetchExtraAborting } from '../../isomorphic/utils/get-fetch-extra-aborting'; +import { getFetchLogging } from '../../isomorphic/utils/get-fetch-logging'; +import { FetchLogging } from '../../isomorphic/utils/fetch-logging'; import { PageAssets } from '../../isomorphic/types'; import { RESPONSE_EVENT_TYPE } from '../../isomorphic/constants'; import { getPageResponseFormat } from '../../server/utils/get-page-response-format'; diff --git a/src/preset/isomorphic/providers/index.ts b/src/preset/isomorphic/providers/index.ts index 0e97f04f..762394e8 100644 --- a/src/preset/isomorphic/providers/index.ts +++ b/src/preset/isomorphic/providers/index.ts @@ -3,7 +3,8 @@ import { BaseConfig } from '../../../config/types'; import { Resolve } from '../../../di'; import { LogMiddlewareHandlerInit } from '../../../utils/axios/middleware/log'; import { KnownToken } from '../../../tokens'; -import { AxiosLogging, SagaLogging } from '../utils'; +import { AxiosLogging } from '../utils/axios-logging'; +import { SagaLogging } from '../utils/saga-logging'; import createSagaMiddleware, { SagaMiddleware } from 'redux-saga'; import { applyMiddleware, configureFetch } from '../../../http'; import { CreateAxiosDefaults } from 'axios'; diff --git a/src/preset/isomorphic/utils/__test__/axios-logging.test.ts b/src/preset/isomorphic/utils/__test__/axios-logging.test.ts new file mode 100644 index 00000000..64072ca5 --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/axios-logging.test.ts @@ -0,0 +1,325 @@ +import { + AxiosDefaults, + AxiosRequestConfig, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; +import { Breadcrumb, DetailedError, Logger, createLogger } from '../../../../log'; +import { AxiosLogging } from '../axios-logging'; +import { severityFromStatus } from '../severity-from-status'; + +describe('AxiosLogging', () => { + const logger: Logger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + subscribe: jest.fn(), + }; + + beforeEach(() => { + (logger.info as jest.Mock).mockClear(); + (logger.error as jest.Mock).mockClear(); + }); + + it('methods should do nothing when disabled', () => { + const spy = jest.fn(); + const someLogger = createLogger(); + const handler = new AxiosLogging(someLogger, { config: {}, defaults: { headers: {} as any } }); + + someLogger.subscribe(spy); + handler.disabled = () => true; + + expect(spy).toHaveBeenCalledTimes(0); + + handler.beforeRequest(); + + expect(spy).toHaveBeenCalledTimes(0); + + handler.afterResponse({ + response: { + status: 200, + statusText: '200', + data: {}, + headers: {}, + config: {} as any, + }, + config: {}, + defaults: { headers: {} as any }, + }); + + expect(spy).toHaveBeenCalledTimes(0); + + handler.onCatch({ + config: {}, + defaults: { headers: {} as any }, + error: new Error('fake error'), + }); + + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('should log ready url properly when baseURL and url provided', async () => { + const config: InternalAxiosRequestConfig = { + url: '/foo/bar', + } as any; + + const defaults: AxiosDefaults = { + headers: {} as any, + baseURL: 'https://sima.com/', + }; + + const response: AxiosResponse = { + status: 200, + statusText: '200', + data: {}, + headers: {}, + config, + }; + + const handler = new AxiosLogging(logger, { config, defaults }); + + handler.beforeRequest(); + handler.afterResponse({ config, defaults, response }); + + expect(logger.info).toHaveBeenCalledTimes(2); + expect((logger.info as jest.Mock).mock.calls[0]).toEqual([ + new Breadcrumb({ + category: 'http.request', + type: 'http', + data: { + url: 'https://sima.com/foo/bar', + method: 'get', + params: undefined, + }, + level: 'info', + }), + ]); + expect((logger.info as jest.Mock).mock.calls[1]).toEqual([ + new Breadcrumb({ + category: 'http.response', + type: 'http', + data: { + url: 'https://sima.com/foo/bar', + method: 'get', + params: undefined, + status_code: 200, + }, + level: 'info', + }), + ]); + }); + + it('should log ready url properly when only baseURL provided', async () => { + const config: InternalAxiosRequestConfig = {} as any; + + const defaults: AxiosDefaults = { + headers: {} as any, + baseURL: 'https://sima.com/', + }; + + const response: AxiosResponse = { + status: 200, + statusText: '200', + data: {}, + headers: {}, + config: {} as any, + }; + + const handler = new AxiosLogging(logger, { config, defaults }); + + handler.beforeRequest(); + handler.afterResponse({ config, defaults, response }); + + expect(logger.info).toHaveBeenCalledTimes(2); + expect((logger.info as jest.Mock).mock.calls[0]).toEqual([ + new Breadcrumb({ + category: 'http.request', + type: 'http', + data: { + url: 'https://sima.com/', + method: 'get', + params: undefined, + }, + level: 'info', + }), + ]); + expect((logger.info as jest.Mock).mock.calls[1]).toEqual([ + new Breadcrumb({ + category: 'http.response', + type: 'http', + data: { + url: 'https://sima.com/', + method: 'get', + params: undefined, + status_code: 200, + }, + level: 'info', + }), + ]); + }); + + it('should log ready url properly when only url provided', async () => { + const config: AxiosRequestConfig = { + url: 'https://ya.ru', + params: { foo: 'bar' }, + }; + + const defaults: AxiosDefaults = { + headers: {} as any, + }; + + const response: AxiosResponse = { + status: 200, + statusText: '200', + data: {}, + headers: {}, + config: {} as any, + }; + + const handler = new AxiosLogging(logger, { config, defaults }); + + handler.beforeRequest(); + handler.afterResponse({ config, defaults, response }); + + expect(logger.info).toHaveBeenCalledTimes(2); + expect((logger.info as jest.Mock).mock.calls[0]).toEqual([ + new Breadcrumb({ + category: 'http.request', + type: 'http', + data: { + url: 'https://ya.ru', + method: 'get', + params: { foo: 'bar' }, + }, + level: 'info', + }), + ]); + expect((logger.info as jest.Mock).mock.calls[1]).toEqual([ + new Breadcrumb({ + category: 'http.response', + type: 'http', + data: { + url: 'https://ya.ru', + method: 'get', + params: { foo: 'bar' }, + status_code: 200, + }, + level: 'info', + }), + ]); + }); + + it('should log axios error', async () => { + const error = { + name: 'TestError', + message: 'test', + response: { status: 407 }, + isAxiosError: true, + toJSON: () => error, + }; + + const config: AxiosRequestConfig = { + url: 'https://ya.ru', + params: { bar: 'baz' }, + }; + + const defaults: AxiosDefaults = { + headers: {} as any, + }; + + const handler = new AxiosLogging(logger, { config, defaults }); + + expect(logger.error).toHaveBeenCalledTimes(0); + expect(logger.info).toHaveBeenCalledTimes(0); + + handler.beforeRequest(); + handler.onCatch({ config, defaults, error }); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledTimes(2); + }); + + it('should log axios error without status', async () => { + const error = { + name: 'TestError', + message: 'test', + response: { status: undefined }, + isAxiosError: true, + toJSON: () => error, + }; + + const config: AxiosRequestConfig = { + url: 'https://ya.ru', + }; + + const defaults: AxiosDefaults = { + headers: {} as any, + }; + + const handler = new AxiosLogging(logger, { config, defaults }); + + expect(logger.error).toHaveBeenCalledTimes(0); + expect(logger.info).toHaveBeenCalledTimes(0); + + handler.beforeRequest(); + handler.onCatch({ config, defaults, error }); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledTimes(1); + + const loggerErrorArgument: any = (logger.error as jest.Mock).mock.calls[0][0]; + + expect(loggerErrorArgument instanceof DetailedError).toBe(true); + expect(loggerErrorArgument).toEqual( + new DetailedError(`HTTP request failed, status code: UNKNOWN, error message: test`, { + level: severityFromStatus(error.response?.status), + context: { + key: 'Request details', + data: { + error, + url: 'https://ya.ru', + baseURL: undefined, + method: 'GET', + headers: { + ...config.headers, + ...defaults.headers.get, + }, + params: { bar: 'baz' }, + data: undefined, + }, + }, + }), + ); + + expect(loggerErrorArgument.data.context[1].data.error).toBe(error); + }); + + it('should log NOT axios error', async () => { + const error = { + name: 'TestError', + message: 'test', + response: { status: 407 }, + }; + + const config: AxiosRequestConfig = { + url: 'https://ya.ru', + }; + + const defaults: AxiosDefaults = { + headers: {} as any, + }; + + const handler = new AxiosLogging(logger, { config, defaults }); + + expect(logger.error).toHaveBeenCalledTimes(0); + expect(logger.info).toHaveBeenCalledTimes(0); + + handler.beforeRequest(); + handler.onCatch({ config, defaults, error }); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/preset/isomorphic/utils/__test__/disableable.test.ts b/src/preset/isomorphic/utils/__test__/disableable.test.ts new file mode 100644 index 00000000..2f569f44 --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/disableable.test.ts @@ -0,0 +1,21 @@ +import { Disableable } from '../disableable'; + +describe('Disableable', () => { + it('should has isDisabled method', () => { + const disableable = new Disableable(); + expect(disableable.isDisabled()).toBe(false); + + disableable.disabled = true; + expect(disableable.isDisabled()).toBe(true); + + disableable.disabled = false; + expect(disableable.isDisabled()).toBe(false); + + let flag = true; + disableable.disabled = () => flag; + expect(disableable.isDisabled()).toBe(true); + + flag = false; + expect(disableable.isDisabled()).toBe(false); + }); +}); diff --git a/src/preset/isomorphic/utils/__test__/display-url.test.ts b/src/preset/isomorphic/utils/__test__/display-url.test.ts new file mode 100644 index 00000000..47e6da3e --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/display-url.test.ts @@ -0,0 +1,73 @@ +import { displayUrl } from '../display-url'; + +describe('displayUrl', () => { + const cases: Array<{ name: string; url: string; baseURL: string; expectedUrl: string }> = [ + // baseURL и url + { + name: 'baseURL (with trailing slash) + url (with leading slash)', + baseURL: 'https://www.base.com/', + url: '/user/current', + expectedUrl: 'https://www.base.com/user/current', + }, + { + name: 'baseURL (no trailing slash) + url (no leading slash)', + baseURL: 'https://www.base.com', + url: 'user/current', + expectedUrl: 'https://www.base.com/user/current', + }, + { + name: 'baseURL (no trailing slash) + url (with leading slash)', + baseURL: 'https://www.base.com', + url: '/user/current', + expectedUrl: 'https://www.base.com/user/current', + }, + { + name: 'baseURL (with trailing slash) + url (no leading slash)', + baseURL: 'https://www.base.com/', + url: 'admin/all', + expectedUrl: 'https://www.base.com/admin/all', + }, + + // только baseURL + { + name: 'only baseURL', + baseURL: 'www.test.com', + url: '', + expectedUrl: 'www.test.com', + }, + { + name: 'only baseURL (with trailing slash)', + baseURL: 'www.test.com/', + url: '', + expectedUrl: 'www.test.com/', + }, + + // только url + { + name: 'only url (with leading slash)', + baseURL: '', + url: '/hello/world', + expectedUrl: '/hello/world', + }, + { + name: 'only url (no leading slash)', + baseURL: '', + url: 'some/path', + expectedUrl: 'some/path', + }, + + // ничего + { + name: 'no baseURL + no url', + baseURL: '', + url: '', + expectedUrl: '[empty]', + }, + ]; + + for (const { baseURL, url, expectedUrl, ...meta } of cases) { + it(`${meta.name}`, () => { + expect(displayUrl(baseURL, url)).toBe(expectedUrl); + }); + } +}); diff --git a/src/preset/isomorphic/utils/__test__/fetch-logging.test.ts b/src/preset/isomorphic/utils/__test__/fetch-logging.test.ts new file mode 100644 index 00000000..6ed919b8 --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/fetch-logging.test.ts @@ -0,0 +1,180 @@ +import { FetchUtil } from '../../../../http'; +import { Breadcrumb, DetailedError, createLogger } from '../../../../log'; +import { FetchLogging } from '../fetch-logging'; + +describe('FetchLogging', () => { + it('methods should do nothing when disabled', () => { + const spy = jest.fn(); + const logger = createLogger(); + const handler = new FetchLogging(logger); + + logger.subscribe(spy); + handler.disabled = true; + + expect(spy).toHaveBeenCalledTimes(0); + + handler.onRequest({ + request: new Request('https://test.com'), + }); + + expect(spy).toHaveBeenCalledTimes(0); + + handler.onResponse({ + request: new Request('https://test.com'), + response: new Response('foobar'), + }); + + expect(spy).toHaveBeenCalledTimes(0); + + handler.onCatch({ + request: new Request('https://test.com'), + error: new Error('fake error'), + }); + + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('onRequest should work properly', () => { + const spy = jest.fn(); + const logger = createLogger(); + const handler = new FetchLogging(logger); + + logger.subscribe(spy); + + expect(spy).toHaveBeenCalledTimes(0); + + handler.onRequest({ + request: new Request( + FetchUtil.withParams('https://test.com', { + foo: 'bar', + }), + { method: 'GET' }, + ), + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'info', + data: new Breadcrumb({ + category: 'http.request', + type: 'http', + data: { + url: 'https://test.com/', + method: 'GET', + params: { foo: 'bar' }, + }, + level: 'info', + }), + }); + }); + + it('onResponse should work properly', () => { + const spy = jest.fn(); + const logger = createLogger(); + const handler = new FetchLogging(logger); + + logger.subscribe(spy); + + expect(spy).toHaveBeenCalledTimes(0); + + handler.onResponse({ + request: new Request( + FetchUtil.withParams('https://test.com', { + foo: 'bar', + }), + { method: 'GET' }, + ), + response: new Response('', { status: 201 }), + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'info', + data: new Breadcrumb({ + category: 'http.response', + type: 'http', + data: { + url: 'https://test.com/', + method: 'GET', + params: { foo: 'bar' }, + status_code: 201, + }, + level: 'info', + }), + }); + }); + + it('onResponse should handles "ok" property', () => { + const spy = jest.fn(); + const logger = createLogger(); + const handler = new FetchLogging(logger); + + logger.subscribe(spy); + + expect(spy).toHaveBeenCalledTimes(0); + + handler.onResponse({ + request: new Request( + FetchUtil.withParams('https://test.com', { + foo: 'bar', + }), + { method: 'GET' }, + ), + response: new Response('', { status: 500 }), + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'info', + data: new Breadcrumb({ + category: 'http.response', + type: 'http', + data: { + url: 'https://test.com/', + method: 'GET', + params: { foo: 'bar' }, + status_code: 500, + }, + level: 'error', + }), + }); + }); + + it('onCatch should work properly', () => { + const spy = jest.fn(); + const logger = createLogger(); + const handler = new FetchLogging(logger); + + logger.subscribe(spy); + + expect(spy).toHaveBeenCalledTimes(0); + + handler.onCatch({ + request: new Request( + FetchUtil.withParams('https://test.com', { + foo: 'bar', + }), + { method: 'GET' }, + ), + error: new Error('Test error'), + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'error', + data: new DetailedError('Error: Test error', { + level: 'error', + context: [ + { + key: 'Outgoing request details', + data: { + url: 'https://test.com', + method: 'GET', + params: { foo: 'bar' }, + }, + }, + ], + }), + }); + }); +}); diff --git a/src/preset/isomorphic/utils/__test__/get-fetch-error-logging.test.ts b/src/preset/isomorphic/utils/__test__/get-fetch-error-logging.test.ts new file mode 100644 index 00000000..8a224fdb --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/get-fetch-error-logging.test.ts @@ -0,0 +1,43 @@ +import { getFetchErrorLogging } from '../get-fetch-error-logging'; + +describe('getFetchErrorLogging', () => { + it('should log only catch stage', async () => { + const requestSpy = jest.fn(); + const responseSpy = jest.fn(); + const catchSpy = jest.fn(); + + const middleware = getFetchErrorLogging({ + onRequest: requestSpy, + onResponse: responseSpy, + onCatch: catchSpy, + }); + + await Promise.resolve( + middleware(new Request('http://test.com'), () => Promise.reject('FAKE ERROR')), + ).catch(() => {}); + + expect(requestSpy).toHaveBeenCalledTimes(0); + expect(responseSpy).toHaveBeenCalledTimes(0); + expect(catchSpy).toHaveBeenCalledTimes(1); + }); + + it('should handle function as handlerInit', async () => { + const requestSpy = jest.fn(); + const responseSpy = jest.fn(); + const catchSpy = jest.fn(); + + const middleware = getFetchErrorLogging(() => ({ + onRequest: requestSpy, + onResponse: responseSpy, + onCatch: catchSpy, + })); + + await Promise.resolve( + middleware(new Request('http://test.com'), () => Promise.reject('FAKE ERROR')), + ).catch(() => {}); + + expect(requestSpy).toHaveBeenCalledTimes(0); + expect(responseSpy).toHaveBeenCalledTimes(0); + expect(catchSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/preset/isomorphic/utils/__test__/get-fetch-extra-aborting.test.ts b/src/preset/isomorphic/utils/__test__/get-fetch-extra-aborting.test.ts new file mode 100644 index 00000000..168e05d3 --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/get-fetch-extra-aborting.test.ts @@ -0,0 +1,39 @@ +import { getFetchExtraAborting } from '../get-fetch-extra-aborting'; + +describe('getFetchExtraAborting', () => { + it('should handle controller', async () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const middleware = getFetchExtraAborting(controller1); + + let request = new Request('http://stub.com'); + + await middleware(new Request('http://test.com', { signal: controller2.signal }), req => { + request = req; + return Promise.resolve(new Response('OK')); + }); + + expect(request.signal.aborted).toBe(false); + + controller1.abort(); + expect(request.signal.aborted).toBe(true); + }); + + it('should handle controller from request', async () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const middleware = getFetchExtraAborting(controller1); + + let request = new Request('http://stub.com'); + + await middleware(new Request('http://test.com', { signal: controller2.signal }), req => { + request = req; + return Promise.resolve(new Response('OK')); + }); + + expect(request.signal.aborted).toBe(false); + + controller2.abort(); + expect(request.signal.aborted).toBe(true); + }); +}); diff --git a/src/preset/isomorphic/utils/__test__/get-fetch-logging.test.ts b/src/preset/isomorphic/utils/__test__/get-fetch-logging.test.ts new file mode 100644 index 00000000..3dc88ce9 --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/get-fetch-logging.test.ts @@ -0,0 +1,43 @@ +import { getFetchLogging } from '../get-fetch-logging'; + +describe('getFetchLogging', () => { + it('should log only request and response', async () => { + const requestSpy = jest.fn(); + const responseSpy = jest.fn(); + const catchSpy = jest.fn(); + + const middleware = getFetchLogging({ + onRequest: requestSpy, + onResponse: responseSpy, + onCatch: catchSpy, + }); + + await middleware(new Request('http://test.com'), () => + Promise.resolve(new Response('OK')), + ); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(responseSpy).toHaveBeenCalledTimes(1); + expect(catchSpy).toHaveBeenCalledTimes(0); + }); + + it('should handle function as handlerInit', async () => { + const requestSpy = jest.fn(); + const responseSpy = jest.fn(); + const catchSpy = jest.fn(); + + const middleware = getFetchLogging(() => ({ + onRequest: requestSpy, + onResponse: responseSpy, + onCatch: catchSpy, + })); + + await middleware(new Request('http://test.com'), () => + Promise.resolve(new Response('OK')), + ); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(responseSpy).toHaveBeenCalledTimes(1); + expect(catchSpy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/preset/isomorphic/utils/__test__/http-api-host-pool.test.ts b/src/preset/isomorphic/utils/__test__/http-api-host-pool.test.ts new file mode 100644 index 00000000..cb00fd60 --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/http-api-host-pool.test.ts @@ -0,0 +1,90 @@ +import { createConfigSource } from '../../../../config'; +import { HttpApiHostPool } from '../http-api-host-pool'; + +describe('HttpApiHostPool', () => { + it('.get() should return value from map', () => { + const source = createConfigSource({ + API_FOO: 'http://www.foo.com', + API_BAR: 'http://www.bar.com', + }); + + const pool = new HttpApiHostPool({ foo: 'API_FOO', bar: 'API_BAR' }, source); + + expect(pool.get('foo')).toBe('http://www.foo.com'); + expect(pool.get('bar')).toBe('http://www.bar.com'); + }); + + it('.get() should throw error when variable name is undefined', () => { + const source = createConfigSource({ + API_FOO: 'http://www.foo.com', + }); + + const pool = new HttpApiHostPool({ foo: 'API_FOO' }, source); + + expect(pool.get('foo')).toBe('http://www.foo.com'); + + expect(() => { + pool.get('bar' as any); + }).toThrow(`Known HTTP API not found by key "bar"`); + }); + + it('should handle absolute option', () => { + const source = createConfigSource({ + API_HOST_FOOBAR: 'http://www.foobar.com', + }); + + const pool = new HttpApiHostPool( + { + foobar: 'API_HOST_FOOBAR', + }, + source, + ); + + expect(pool.get('foobar', { absolute: true })).toEqual('http://www.foobar.com'); + }); + + it('getAll() should return all hosts', () => { + const source = createConfigSource({ + API_HOST_FOO: 'http://www.foo.com', + API_HOST_BAR: 'http://www.bar.com', + API_HOST_BAZ: 'http://www.baz.com', + }); + + const pool = new HttpApiHostPool( + { + foo: 'API_HOST_FOO', + bar: 'API_HOST_BAR', + baz: 'API_HOST_BAZ', + }, + source, + ); + + expect(pool.getAll()).toEqual({ + foo: 'http://www.foo.com', + bar: 'http://www.bar.com', + baz: 'http://www.baz.com', + }); + }); + + it('getAll(keys) should return hosts for keys', () => { + const source = createConfigSource({ + API_HOST_FOO: 'http://www.foo.com', + API_HOST_BAR: 'http://www.bar.com', + API_HOST_BAZ: 'http://www.baz.com', + }); + + const pool = new HttpApiHostPool( + { + foo: 'API_HOST_FOO', + bar: 'API_HOST_BAR', + baz: 'API_HOST_BAZ', + }, + source, + ); + + expect(pool.getAll(['foo', 'baz'])).toEqual({ + foo: 'http://www.foo.com', + baz: 'http://www.baz.com', + }); + }); +}); diff --git a/src/preset/isomorphic/utils/__test__/http-status.test.ts b/src/preset/isomorphic/utils/__test__/http-status.test.ts new file mode 100644 index 00000000..89cd37dc --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/http-status.test.ts @@ -0,0 +1,92 @@ +import { HttpStatus } from '../http-status'; + +describe('HttpStatus', () => { + it('isOk', () => { + expect(HttpStatus.isOk(200)).toBe(true); + expect(HttpStatus.isOk(201)).toBe(false); + expect(HttpStatus.isOk(199)).toBe(false); + }); + + it('isPostOk', () => { + expect(HttpStatus.isPostOk(201)).toBe(true); + expect(HttpStatus.isPostOk(200)).toBe(false); + expect(HttpStatus.isPostOk(300)).toBe(false); + expect(HttpStatus.isPostOk(400)).toBe(false); + }); + + it('isDeleteOk', () => { + expect(HttpStatus.isDeleteOk(204)).toBe(true); + expect(HttpStatus.isDeleteOk(200)).toBe(true); + expect(HttpStatus.isDeleteOk(199)).toBe(false); + expect(HttpStatus.isDeleteOk(300)).toBe(false); + expect(HttpStatus.isDeleteOk(400)).toBe(false); + }); + + describe('createMiddleware', () => { + const middleware = HttpStatus.axiosMiddleware(); + + it('should NOT change validateStatus when is already defined as null', async () => { + const config = { validateStatus: null }; + const next = jest.fn(); + const defaults = { headers: {} as any }; + + await middleware(config, next, defaults); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(config); + }); + + it('should NOT set validateStatus when is already defined as function', async () => { + const config = { validateStatus: () => false }; + const next = jest.fn(); + const defaults = { headers: {} as any }; + + await middleware(config, next, defaults); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(config); + }); + + it('should NOT set validateStatus when is already defined as null in defaults', async () => { + const config = {}; + const next = jest.fn(); + const defaults = { headers: {} as any, validateStatus: null }; + + await middleware(config, next, defaults); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(config); + }); + + it('should NOT set validateStatus when is already defined as function in defaults', async () => { + const config = {}; + const next = jest.fn(); + const defaults = { headers: {} as any, validateStatus: () => false }; + + await middleware(config, next, defaults); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(config); + }); + + it('should set validateStatus for known statuses in config', async () => { + const cases: Array<[string | undefined, (status: unknown) => boolean]> = [ + [undefined, HttpStatus.isOk], + ['get', HttpStatus.isOk], + ['GET', HttpStatus.isOk], + ['put', HttpStatus.isOk], + ['PUT', HttpStatus.isOk], + ['post', HttpStatus.isPostOk], + ['POST', HttpStatus.isPostOk], + ['delete', HttpStatus.isDeleteOk], + ['DELETE', HttpStatus.isDeleteOk], + ]; + + for (const [method, validator] of cases) { + const config = { method }; + const next = jest.fn(); + const defaults = { headers: {} as any }; + + await middleware(config, next, defaults); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith({ ...config, validateStatus: validator }); + } + }); + }); +}); diff --git a/src/preset/isomorphic/utils/__test__/index.test.ts b/src/preset/isomorphic/utils/__test__/index.test.ts deleted file mode 100644 index 159af01e..00000000 --- a/src/preset/isomorphic/utils/__test__/index.test.ts +++ /dev/null @@ -1,951 +0,0 @@ -import { Env } from '@humanwhocodes/env'; -import { - AxiosRequestConfig, - AxiosDefaults, - AxiosResponse, - InternalAxiosRequestConfig, -} from 'axios'; -import { Logger, Breadcrumb, DetailedError, createLogger } from '../../../../log'; -import { - HttpApiHostPool, - AxiosLogging, - SagaLogging, - severityFromStatus, - HttpStatus, - displayUrl, - FetchLogging, - getFetchLogging, - getFetchErrorLogging, - getFetchExtraAborting, -} from '..'; -import { FetchUtil } from '../../../../http'; - -describe('displayUrl', () => { - const cases: Array<{ name: string; url: string; baseURL: string; expectedUrl: string }> = [ - // baseURL и url - { - name: 'baseURL (with trailing slash) + url (with leading slash)', - baseURL: 'https://www.base.com/', - url: '/user/current', - expectedUrl: 'https://www.base.com/user/current', - }, - { - name: 'baseURL (no trailing slash) + url (no leading slash)', - baseURL: 'https://www.base.com', - url: 'user/current', - expectedUrl: 'https://www.base.com/user/current', - }, - { - name: 'baseURL (no trailing slash) + url (with leading slash)', - baseURL: 'https://www.base.com', - url: '/user/current', - expectedUrl: 'https://www.base.com/user/current', - }, - { - name: 'baseURL (with trailing slash) + url (no leading slash)', - baseURL: 'https://www.base.com/', - url: 'admin/all', - expectedUrl: 'https://www.base.com/admin/all', - }, - - // только baseURL - { - name: 'only baseURL', - baseURL: 'www.test.com', - url: '', - expectedUrl: 'www.test.com', - }, - { - name: 'only baseURL (with trailing slash)', - baseURL: 'www.test.com/', - url: '', - expectedUrl: 'www.test.com/', - }, - - // только url - { - name: 'only url (with leading slash)', - baseURL: '', - url: '/hello/world', - expectedUrl: '/hello/world', - }, - { - name: 'only url (no leading slash)', - baseURL: '', - url: 'some/path', - expectedUrl: 'some/path', - }, - - // ничего - { - name: 'no baseURL + no url', - baseURL: '', - url: '', - expectedUrl: '[empty]', - }, - ]; - - for (const { baseURL, url, expectedUrl, ...meta } of cases) { - it(`${meta.name}`, () => { - expect(displayUrl(baseURL, url)).toBe(expectedUrl); - }); - } -}); - -describe('HttpApiHostPool', () => { - it('.get() should return value from map', () => { - const source = new Env({ - API_FOO: 'http://www.foo.com', - API_BAR: 'http://www.bar.com', - }); - - const pool = new HttpApiHostPool({ foo: 'API_FOO', bar: 'API_BAR' }, source); - - expect(pool.get('foo')).toBe('http://www.foo.com'); - expect(pool.get('bar')).toBe('http://www.bar.com'); - }); - - it('.get() should throw error when variable name is undefined', () => { - const source = new Env({ - API_FOO: 'http://www.foo.com', - }); - - const pool = new HttpApiHostPool({ foo: 'API_FOO' }, source); - - expect(pool.get('foo')).toBe('http://www.foo.com'); - - expect(() => { - pool.get('bar' as any); - }).toThrow(`Known HTTP API not found by key "bar"`); - }); - - it('should handle absolute option', () => { - const source = new Env({ - API_HOST_FOOBAR: 'http://www.foobar.com', - }); - - const pool = new HttpApiHostPool( - { - foobar: 'API_HOST_FOOBAR', - }, - source, - ); - - expect(pool.get('foobar', { absolute: true })).toEqual('http://www.foobar.com'); - }); - - it('getAll() should return all hosts', () => { - const source = new Env({ - API_HOST_FOO: 'http://www.foo.com', - API_HOST_BAR: 'http://www.bar.com', - API_HOST_BAZ: 'http://www.baz.com', - }); - - const pool = new HttpApiHostPool( - { - foo: 'API_HOST_FOO', - bar: 'API_HOST_BAR', - baz: 'API_HOST_BAZ', - }, - source, - ); - - expect(pool.getAll()).toEqual({ - foo: 'http://www.foo.com', - bar: 'http://www.bar.com', - baz: 'http://www.baz.com', - }); - }); - - it('getAll(keys) should return hosts for keys', () => { - const source = new Env({ - API_HOST_FOO: 'http://www.foo.com', - API_HOST_BAR: 'http://www.bar.com', - API_HOST_BAZ: 'http://www.baz.com', - }); - - const pool = new HttpApiHostPool( - { - foo: 'API_HOST_FOO', - bar: 'API_HOST_BAR', - baz: 'API_HOST_BAZ', - }, - source, - ); - - expect(pool.getAll(['foo', 'baz'])).toEqual({ - foo: 'http://www.foo.com', - baz: 'http://www.baz.com', - }); - }); -}); - -describe('severityFromStatus', () => { - it('should works', () => { - expect(severityFromStatus(200)).toBe('info'); - expect(severityFromStatus(201)).toBe('info'); - expect(severityFromStatus(204)).toBe('info'); - - expect(severityFromStatus(300)).toBe('warning'); - expect(severityFromStatus(302)).toBe('warning'); - expect(severityFromStatus(400)).toBe('warning'); - expect(severityFromStatus(404)).toBe('warning'); - expect(severityFromStatus(422)).toBe('warning'); - expect(severityFromStatus(499)).toBe('warning'); - - expect(severityFromStatus(undefined)).toBe('error'); - expect(severityFromStatus(100)).toBe('error'); - expect(severityFromStatus(199)).toBe('error'); - expect(severityFromStatus(500)).toBe('error'); - expect(severityFromStatus(503)).toBe('error'); - }); -}); - -describe('AxiosLogging', () => { - const logger: Logger = { - log: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - subscribe: jest.fn(), - }; - - beforeEach(() => { - (logger.info as jest.Mock).mockClear(); - (logger.error as jest.Mock).mockClear(); - }); - - it('methods should do nothing when disabled', () => { - const spy = jest.fn(); - const someLogger = createLogger(); - const handler = new AxiosLogging(someLogger, { config: {}, defaults: { headers: {} as any } }); - - someLogger.subscribe(spy); - handler.disabled = () => true; - - expect(spy).toHaveBeenCalledTimes(0); - - handler.beforeRequest(); - - expect(spy).toHaveBeenCalledTimes(0); - - handler.afterResponse({ - response: { - status: 200, - statusText: '200', - data: {}, - headers: {}, - config: {} as any, - }, - config: {}, - defaults: { headers: {} as any }, - }); - - expect(spy).toHaveBeenCalledTimes(0); - - handler.onCatch({ - config: {}, - defaults: { headers: {} as any }, - error: new Error('fake error'), - }); - - expect(spy).toHaveBeenCalledTimes(0); - }); - - it('should log ready url properly when baseURL and url provided', async () => { - const config: InternalAxiosRequestConfig = { - url: '/foo/bar', - } as any; - - const defaults: AxiosDefaults = { - headers: {} as any, - baseURL: 'https://sima.com/', - }; - - const response: AxiosResponse = { - status: 200, - statusText: '200', - data: {}, - headers: {}, - config, - }; - - const handler = new AxiosLogging(logger, { config, defaults }); - - handler.beforeRequest(); - handler.afterResponse({ config, defaults, response }); - - expect(logger.info).toHaveBeenCalledTimes(2); - expect((logger.info as jest.Mock).mock.calls[0]).toEqual([ - new Breadcrumb({ - category: 'http.request', - type: 'http', - data: { - url: 'https://sima.com/foo/bar', - method: 'get', - params: undefined, - }, - level: 'info', - }), - ]); - expect((logger.info as jest.Mock).mock.calls[1]).toEqual([ - new Breadcrumb({ - category: 'http.response', - type: 'http', - data: { - url: 'https://sima.com/foo/bar', - method: 'get', - params: undefined, - status_code: 200, - }, - level: 'info', - }), - ]); - }); - - it('should log ready url properly when only baseURL provided', async () => { - const config: InternalAxiosRequestConfig = {} as any; - - const defaults: AxiosDefaults = { - headers: {} as any, - baseURL: 'https://sima.com/', - }; - - const response: AxiosResponse = { - status: 200, - statusText: '200', - data: {}, - headers: {}, - config: {} as any, - }; - - const handler = new AxiosLogging(logger, { config, defaults }); - - handler.beforeRequest(); - handler.afterResponse({ config, defaults, response }); - - expect(logger.info).toHaveBeenCalledTimes(2); - expect((logger.info as jest.Mock).mock.calls[0]).toEqual([ - new Breadcrumb({ - category: 'http.request', - type: 'http', - data: { - url: 'https://sima.com/', - method: 'get', - params: undefined, - }, - level: 'info', - }), - ]); - expect((logger.info as jest.Mock).mock.calls[1]).toEqual([ - new Breadcrumb({ - category: 'http.response', - type: 'http', - data: { - url: 'https://sima.com/', - method: 'get', - params: undefined, - status_code: 200, - }, - level: 'info', - }), - ]); - }); - - it('should log ready url properly when only url provided', async () => { - const config: AxiosRequestConfig = { - url: 'https://ya.ru', - params: { foo: 'bar' }, - }; - - const defaults: AxiosDefaults = { - headers: {} as any, - }; - - const response: AxiosResponse = { - status: 200, - statusText: '200', - data: {}, - headers: {}, - config: {} as any, - }; - - const handler = new AxiosLogging(logger, { config, defaults }); - - handler.beforeRequest(); - handler.afterResponse({ config, defaults, response }); - - expect(logger.info).toHaveBeenCalledTimes(2); - expect((logger.info as jest.Mock).mock.calls[0]).toEqual([ - new Breadcrumb({ - category: 'http.request', - type: 'http', - data: { - url: 'https://ya.ru', - method: 'get', - params: { foo: 'bar' }, - }, - level: 'info', - }), - ]); - expect((logger.info as jest.Mock).mock.calls[1]).toEqual([ - new Breadcrumb({ - category: 'http.response', - type: 'http', - data: { - url: 'https://ya.ru', - method: 'get', - params: { foo: 'bar' }, - status_code: 200, - }, - level: 'info', - }), - ]); - }); - - it('should log axios error', async () => { - const error = { - name: 'TestError', - message: 'test', - response: { status: 407 }, - isAxiosError: true, - toJSON: () => error, - }; - - const config: AxiosRequestConfig = { - url: 'https://ya.ru', - params: { bar: 'baz' }, - }; - - const defaults: AxiosDefaults = { - headers: {} as any, - }; - - const handler = new AxiosLogging(logger, { config, defaults }); - - expect(logger.error).toHaveBeenCalledTimes(0); - expect(logger.info).toHaveBeenCalledTimes(0); - - handler.beforeRequest(); - handler.onCatch({ config, defaults, error }); - - expect(logger.error).toHaveBeenCalledTimes(1); - expect(logger.info).toHaveBeenCalledTimes(2); - }); - - it('should log axios error without status', async () => { - const error = { - name: 'TestError', - message: 'test', - response: { status: undefined }, - isAxiosError: true, - toJSON: () => error, - }; - - const config: AxiosRequestConfig = { - url: 'https://ya.ru', - }; - - const defaults: AxiosDefaults = { - headers: {} as any, - }; - - const handler = new AxiosLogging(logger, { config, defaults }); - - expect(logger.error).toHaveBeenCalledTimes(0); - expect(logger.info).toHaveBeenCalledTimes(0); - - handler.beforeRequest(); - handler.onCatch({ config, defaults, error }); - - expect(logger.error).toHaveBeenCalledTimes(1); - expect(logger.info).toHaveBeenCalledTimes(1); - - const loggerErrorArgument: any = (logger.error as jest.Mock).mock.calls[0][0]; - - expect(loggerErrorArgument instanceof DetailedError).toBe(true); - expect(loggerErrorArgument).toEqual( - new DetailedError(`HTTP request failed, status code: UNKNOWN, error message: test`, { - level: severityFromStatus(error.response?.status), - context: { - key: 'Request details', - data: { - error, - url: 'https://ya.ru', - baseURL: undefined, - method: 'GET', - headers: { - ...config.headers, - ...defaults.headers.get, - }, - params: { bar: 'baz' }, - data: undefined, - }, - }, - }), - ); - - expect(loggerErrorArgument.data.context[1].data.error).toBe(error); - }); - - it('should log NOT axios error', async () => { - const error = { - name: 'TestError', - message: 'test', - response: { status: 407 }, - }; - - const config: AxiosRequestConfig = { - url: 'https://ya.ru', - }; - - const defaults: AxiosDefaults = { - headers: {} as any, - }; - - const handler = new AxiosLogging(logger, { config, defaults }); - - expect(logger.error).toHaveBeenCalledTimes(0); - expect(logger.info).toHaveBeenCalledTimes(0); - - handler.beforeRequest(); - handler.onCatch({ config, defaults, error }); - - expect(logger.error).toHaveBeenCalledTimes(1); - expect(logger.info).toHaveBeenCalledTimes(1); - }); -}); - -describe('SagaLogging', () => { - const logger: Logger = { - log: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - subscribe: jest.fn(), - }; - - beforeEach(() => { - (logger.info as jest.Mock).mockClear(); - (logger.error as jest.Mock).mockClear(); - }); - - it('should handle saga error properly', () => { - const handler = new SagaLogging(logger); - - expect(logger.error).toHaveBeenCalledTimes(0); - handler.onSagaError(new Error('my test error'), { sagaStack: 'my test stack' }); - expect(logger.error).toHaveBeenCalledTimes(1); - }); - - it('should handle config error properly', () => { - const handler = new SagaLogging(logger); - const error = new Error('my test error'); - - expect(logger.error).toHaveBeenCalledTimes(0); - handler.onConfigError(error); - expect(logger.error).toHaveBeenCalledTimes(1); - expect(logger.error).toHaveBeenCalledWith(error); - }); - - it('should handle timeout interrupt properly', () => { - const handler = new SagaLogging(logger); - const info = { timeout: 250 }; - - expect(logger.error).toHaveBeenCalledTimes(0); - handler.onTimeoutInterrupt(info); - expect(logger.error).toHaveBeenCalledTimes(1); - }); -}); - -describe('HttpStatus', () => { - it('isOk', () => { - expect(HttpStatus.isOk(200)).toBe(true); - expect(HttpStatus.isOk(201)).toBe(false); - expect(HttpStatus.isOk(199)).toBe(false); - }); - - it('isPostOk', () => { - expect(HttpStatus.isPostOk(201)).toBe(true); - expect(HttpStatus.isPostOk(200)).toBe(false); - expect(HttpStatus.isPostOk(300)).toBe(false); - expect(HttpStatus.isPostOk(400)).toBe(false); - }); - - it('isDeleteOk', () => { - expect(HttpStatus.isDeleteOk(204)).toBe(true); - expect(HttpStatus.isDeleteOk(200)).toBe(true); - expect(HttpStatus.isDeleteOk(199)).toBe(false); - expect(HttpStatus.isDeleteOk(300)).toBe(false); - expect(HttpStatus.isDeleteOk(400)).toBe(false); - }); - - describe('createMiddleware', () => { - const middleware = HttpStatus.axiosMiddleware(); - - it('should NOT change validateStatus when is already defined as null', async () => { - const config = { validateStatus: null }; - const next = jest.fn(); - const defaults = { headers: {} as any }; - - await middleware(config, next, defaults); - expect(next).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledWith(config); - }); - - it('should NOT set validateStatus when is already defined as function', async () => { - const config = { validateStatus: () => false }; - const next = jest.fn(); - const defaults = { headers: {} as any }; - - await middleware(config, next, defaults); - expect(next).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledWith(config); - }); - - it('should NOT set validateStatus when is already defined as null in defaults', async () => { - const config = {}; - const next = jest.fn(); - const defaults = { headers: {} as any, validateStatus: null }; - - await middleware(config, next, defaults); - expect(next).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledWith(config); - }); - - it('should NOT set validateStatus when is already defined as function in defaults', async () => { - const config = {}; - const next = jest.fn(); - const defaults = { headers: {} as any, validateStatus: () => false }; - - await middleware(config, next, defaults); - expect(next).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledWith(config); - }); - - it('should set validateStatus for known statuses in config', async () => { - const cases: Array<[string | undefined, (status: unknown) => boolean]> = [ - [undefined, HttpStatus.isOk], - ['get', HttpStatus.isOk], - ['GET', HttpStatus.isOk], - ['put', HttpStatus.isOk], - ['PUT', HttpStatus.isOk], - ['post', HttpStatus.isPostOk], - ['POST', HttpStatus.isPostOk], - ['delete', HttpStatus.isDeleteOk], - ['DELETE', HttpStatus.isDeleteOk], - ]; - - for (const [method, validator] of cases) { - const config = { method }; - const next = jest.fn(); - const defaults = { headers: {} as any }; - - await middleware(config, next, defaults); - expect(next).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledWith({ ...config, validateStatus: validator }); - } - }); - }); -}); - -describe('FetchLogging', () => { - it('methods should do nothing when disabled', () => { - const spy = jest.fn(); - const logger = createLogger(); - const handler = new FetchLogging(logger); - - logger.subscribe(spy); - handler.disabled = true; - - expect(spy).toHaveBeenCalledTimes(0); - - handler.onRequest({ - request: new Request('https://test.com'), - }); - - expect(spy).toHaveBeenCalledTimes(0); - - handler.onResponse({ - request: new Request('https://test.com'), - response: new Response('foobar'), - }); - - expect(spy).toHaveBeenCalledTimes(0); - - handler.onCatch({ - request: new Request('https://test.com'), - error: new Error('fake error'), - }); - - expect(spy).toHaveBeenCalledTimes(0); - }); - - it('onRequest should work properly', () => { - const spy = jest.fn(); - const logger = createLogger(); - const handler = new FetchLogging(logger); - - logger.subscribe(spy); - - expect(spy).toHaveBeenCalledTimes(0); - - handler.onRequest({ - request: new Request( - FetchUtil.withParams('https://test.com', { - foo: 'bar', - }), - { method: 'GET' }, - ), - }); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0][0]).toEqual({ - type: 'info', - data: new Breadcrumb({ - category: 'http.request', - type: 'http', - data: { - url: 'https://test.com/', - method: 'GET', - params: { foo: 'bar' }, - }, - level: 'info', - }), - }); - }); - - it('onResponse should work properly', () => { - const spy = jest.fn(); - const logger = createLogger(); - const handler = new FetchLogging(logger); - - logger.subscribe(spy); - - expect(spy).toHaveBeenCalledTimes(0); - - handler.onResponse({ - request: new Request( - FetchUtil.withParams('https://test.com', { - foo: 'bar', - }), - { method: 'GET' }, - ), - response: new Response('', { status: 201 }), - }); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0][0]).toEqual({ - type: 'info', - data: new Breadcrumb({ - category: 'http.response', - type: 'http', - data: { - url: 'https://test.com/', - method: 'GET', - params: { foo: 'bar' }, - status_code: 201, - }, - level: 'info', - }), - }); - }); - - it('onResponse should handles "ok" property', () => { - const spy = jest.fn(); - const logger = createLogger(); - const handler = new FetchLogging(logger); - - logger.subscribe(spy); - - expect(spy).toHaveBeenCalledTimes(0); - - handler.onResponse({ - request: new Request( - FetchUtil.withParams('https://test.com', { - foo: 'bar', - }), - { method: 'GET' }, - ), - response: new Response('', { status: 500 }), - }); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0][0]).toEqual({ - type: 'info', - data: new Breadcrumb({ - category: 'http.response', - type: 'http', - data: { - url: 'https://test.com/', - method: 'GET', - params: { foo: 'bar' }, - status_code: 500, - }, - level: 'error', - }), - }); - }); - - it('onCatch should work properly', () => { - const spy = jest.fn(); - const logger = createLogger(); - const handler = new FetchLogging(logger); - - logger.subscribe(spy); - - expect(spy).toHaveBeenCalledTimes(0); - - handler.onCatch({ - request: new Request( - FetchUtil.withParams('https://test.com', { - foo: 'bar', - }), - { method: 'GET' }, - ), - error: new Error('Test error'), - }); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0][0]).toEqual({ - type: 'error', - data: new DetailedError('Error: Test error', { - level: 'error', - context: [ - { - key: 'Outgoing request details', - data: { - url: 'https://test.com', - method: 'GET', - params: { foo: 'bar' }, - }, - }, - ], - }), - }); - }); -}); - -describe('getFetchLogging', () => { - it('should log only request and response', async () => { - const requestSpy = jest.fn(); - const responseSpy = jest.fn(); - const catchSpy = jest.fn(); - - const middleware = getFetchLogging({ - onRequest: requestSpy, - onResponse: responseSpy, - onCatch: catchSpy, - }); - - await middleware(new Request('http://test.com'), () => - Promise.resolve(new Response('OK')), - ); - - expect(requestSpy).toHaveBeenCalledTimes(1); - expect(responseSpy).toHaveBeenCalledTimes(1); - expect(catchSpy).toHaveBeenCalledTimes(0); - }); - - it('should handle function as handlerInit', async () => { - const requestSpy = jest.fn(); - const responseSpy = jest.fn(); - const catchSpy = jest.fn(); - - const middleware = getFetchLogging(() => ({ - onRequest: requestSpy, - onResponse: responseSpy, - onCatch: catchSpy, - })); - - await middleware(new Request('http://test.com'), () => - Promise.resolve(new Response('OK')), - ); - - expect(requestSpy).toHaveBeenCalledTimes(1); - expect(responseSpy).toHaveBeenCalledTimes(1); - expect(catchSpy).toHaveBeenCalledTimes(0); - }); -}); - -describe('getFetchErrorLogging', () => { - it('should log only catch stage', async () => { - const requestSpy = jest.fn(); - const responseSpy = jest.fn(); - const catchSpy = jest.fn(); - - const middleware = getFetchErrorLogging({ - onRequest: requestSpy, - onResponse: responseSpy, - onCatch: catchSpy, - }); - - await Promise.resolve( - middleware(new Request('http://test.com'), () => Promise.reject('FAKE ERROR')), - ).catch(() => {}); - - expect(requestSpy).toHaveBeenCalledTimes(0); - expect(responseSpy).toHaveBeenCalledTimes(0); - expect(catchSpy).toHaveBeenCalledTimes(1); - }); - - it('should handle function as handlerInit', async () => { - const requestSpy = jest.fn(); - const responseSpy = jest.fn(); - const catchSpy = jest.fn(); - - const middleware = getFetchErrorLogging(() => ({ - onRequest: requestSpy, - onResponse: responseSpy, - onCatch: catchSpy, - })); - - await Promise.resolve( - middleware(new Request('http://test.com'), () => Promise.reject('FAKE ERROR')), - ).catch(() => {}); - - expect(requestSpy).toHaveBeenCalledTimes(0); - expect(responseSpy).toHaveBeenCalledTimes(0); - expect(catchSpy).toHaveBeenCalledTimes(1); - }); -}); - -describe('getFetchExtraAborting', () => { - it('should handle controller', async () => { - const controller1 = new AbortController(); - const controller2 = new AbortController(); - const middleware = getFetchExtraAborting(controller1); - - let request = new Request('http://stub.com'); - - await middleware(new Request('http://test.com', { signal: controller2.signal }), req => { - request = req; - return Promise.resolve(new Response('OK')); - }); - - expect(request.signal.aborted).toBe(false); - - controller1.abort(); - expect(request.signal.aborted).toBe(true); - }); - - it('should handle controller from request', async () => { - const controller1 = new AbortController(); - const controller2 = new AbortController(); - const middleware = getFetchExtraAborting(controller1); - - let request = new Request('http://stub.com'); - - await middleware(new Request('http://test.com', { signal: controller2.signal }), req => { - request = req; - return Promise.resolve(new Response('OK')); - }); - - expect(request.signal.aborted).toBe(false); - - controller2.abort(); - expect(request.signal.aborted).toBe(true); - }); -}); diff --git a/src/preset/isomorphic/utils/__test__/saga-logging.test.ts b/src/preset/isomorphic/utils/__test__/saga-logging.test.ts new file mode 100644 index 00000000..633ddd3b --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/saga-logging.test.ts @@ -0,0 +1,45 @@ +import { Logger } from '../../../../log'; +import { SagaLogging } from '../saga-logging'; + +describe('SagaLogging', () => { + const logger: Logger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + subscribe: jest.fn(), + }; + + beforeEach(() => { + (logger.info as jest.Mock).mockClear(); + (logger.error as jest.Mock).mockClear(); + }); + + it('should handle saga error properly', () => { + const handler = new SagaLogging(logger); + + expect(logger.error).toHaveBeenCalledTimes(0); + handler.onSagaError(new Error('my test error'), { sagaStack: 'my test stack' }); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('should handle config error properly', () => { + const handler = new SagaLogging(logger); + const error = new Error('my test error'); + + expect(logger.error).toHaveBeenCalledTimes(0); + handler.onConfigError(error); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(error); + }); + + it('should handle timeout interrupt properly', () => { + const handler = new SagaLogging(logger); + const info = { timeout: 250 }; + + expect(logger.error).toHaveBeenCalledTimes(0); + handler.onTimeoutInterrupt(info); + expect(logger.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/preset/isomorphic/utils/__test__/severity-from-status.test.ts b/src/preset/isomorphic/utils/__test__/severity-from-status.test.ts new file mode 100644 index 00000000..0b1463ca --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/severity-from-status.test.ts @@ -0,0 +1,22 @@ +import { severityFromStatus } from '../severity-from-status'; + +describe('severityFromStatus', () => { + it('should works', () => { + expect(severityFromStatus(200)).toBe('info'); + expect(severityFromStatus(201)).toBe('info'); + expect(severityFromStatus(204)).toBe('info'); + + expect(severityFromStatus(300)).toBe('warning'); + expect(severityFromStatus(302)).toBe('warning'); + expect(severityFromStatus(400)).toBe('warning'); + expect(severityFromStatus(404)).toBe('warning'); + expect(severityFromStatus(422)).toBe('warning'); + expect(severityFromStatus(499)).toBe('warning'); + + expect(severityFromStatus(undefined)).toBe('error'); + expect(severityFromStatus(100)).toBe('error'); + expect(severityFromStatus(199)).toBe('error'); + expect(severityFromStatus(500)).toBe('error'); + expect(severityFromStatus(503)).toBe('error'); + }); +}); diff --git a/src/preset/isomorphic/utils/axios-logging.ts b/src/preset/isomorphic/utils/axios-logging.ts new file mode 100644 index 00000000..601ea203 --- /dev/null +++ b/src/preset/isomorphic/utils/axios-logging.ts @@ -0,0 +1,159 @@ +import { Breadcrumb, DetailedError, type Logger } from '../../../log'; +import { + type LogMiddlewareHandler, + applyAxiosDefaults, + SharedData, + DoneSharedData, + FailSharedData, +} from '../../../utils/axios'; +import { Disableable } from './disableable'; +import { displayUrl } from './display-url'; +import { severityFromStatus } from './severity-from-status'; +import axios from 'axios'; + +/** + * Обработчик для промежуточного слоя логирования исходящих http-запросов. + * Отправляет хлебные крошки и данные ошибки, пригодные для Sentry. + */ +export class AxiosLogging extends Disableable implements LogMiddlewareHandler { + logger: Logger; + + protected readonly requestInfo: ReturnType & { + readyURL: string; + }; + + /** + * Конструктор. + * @param logger Logger. + * @param data Данные запроса. + */ + constructor(logger: Logger, data: SharedData) { + super(); + const config = applyAxiosDefaults(data.config, data.defaults); + + this.logger = logger; + + this.requestInfo = { + ...config, + readyURL: displayUrl(config.baseURL, config.url), + }; + } + + /** + * Отправит хлебные крошки перед запросом. + */ + beforeRequest() { + if (this.isDisabled()) { + return; + } + + const { readyURL, method, params } = this.requestInfo; + + this.logger.info( + new Breadcrumb({ + category: 'http.request', + type: 'http', + data: { + url: readyURL, + method, + params, + }, + level: 'info', + }), + ); + } + + /** + * Отправит хлебные крошки после запроса. + * @param data Данные ответа. + */ + afterResponse({ response }: DoneSharedData) { + if (this.isDisabled()) { + return; + } + + const { readyURL, method, params } = this.requestInfo; + + this.logger.info( + new Breadcrumb({ + category: 'http.response', + type: 'http', + data: { + url: readyURL, + method, + status_code: response.status, + params, + }, + level: 'info', + }), + ); + } + + /** + * Отправит данные ошибки при перехвате. + * @param data Данные запроса. + */ + onCatch({ error }: FailSharedData) { + if (this.isDisabled()) { + return; + } + + if (axios.isAxiosError(error)) { + const { requestInfo } = this; + const statusCode = error.response?.status || 'UNKNOWN'; + + // @todo выяснить: нужно ли нам отправлять ответы с кодом <500 в Sentry на уровне всех команд + // если да то можно добавить метод в духе errorStatusFilter(s => s !== 422) + this.logger.error( + new DetailedError( + `HTTP request failed, status code: ${statusCode}, error message: ${error.message}`, + { + level: severityFromStatus(error.response?.status), + context: [ + { + key: 'Request details', + data: { + url: requestInfo.url, + baseURL: requestInfo.baseURL, + method: requestInfo.method, + headers: requestInfo.headers, + data: requestInfo.data, + params: requestInfo.params, + }, + }, + { + key: 'Response details', + data: { + data: error.response?.data, + + // копируем так как в Sentry падает ошибка: **non-serializable** (TypeError: Object.getPrototypeOf(...) is null) + headers: { ...error.response?.headers }, + + error: error.toJSON(), + }, + }, + ], + }, + ), + ); + + if (typeof statusCode === 'number') { + this.logger.info( + new Breadcrumb({ + category: 'http.response', + type: 'http', + data: { + url: requestInfo.readyURL, + method: requestInfo.method, + status_code: statusCode, + params: requestInfo.params, + }, + level: 'error', + }), + ); + } + } else { + this.logger.error(error); + } + } +} diff --git a/src/preset/isomorphic/utils/disableable.ts b/src/preset/isomorphic/utils/disableable.ts new file mode 100644 index 00000000..598607a4 --- /dev/null +++ b/src/preset/isomorphic/utils/disableable.ts @@ -0,0 +1,27 @@ +/** + * Объект, который может быть помечен как disabled. + * @todo Возможно стоит заменить наследование от этого класса на передачу параметра в конструктор. + * Например в виде объекта класса DisableController (по аналогии с AbortController). + * Чтобы нельзя было включить обработчик в том месте где хочется. + * Также возможно стоит просто научить классы принимать AbortController. + */ +export class Disableable { + disabled: boolean | (() => boolean); + + /** @inheritdoc */ + constructor() { + this.disabled = false; + } + + /** + * Определяет отключен ли обработчик. + * @return Отключен ли обработчик. + */ + isDisabled() { + if (typeof this.disabled === 'function') { + return this.disabled(); + } + + return this.disabled; + } +} diff --git a/src/preset/isomorphic/utils/display-url.ts b/src/preset/isomorphic/utils/display-url.ts new file mode 100644 index 00000000..8dddeb77 --- /dev/null +++ b/src/preset/isomorphic/utils/display-url.ts @@ -0,0 +1,32 @@ +import type { AxiosRequestConfig } from 'axios'; + +/** + * Объединяет значения опций baseURL и url (axios) в одну строку для логирования. + * @param baseURL Опция baseURL. + * @param url Опция url. + * @return Отображение. Не является валидным URL. + */ +export function displayUrl( + baseURL: AxiosRequestConfig['baseURL'] = '', + url: AxiosRequestConfig['url'] = '', +) { + let result: string; + + switch (true) { + case Boolean(baseURL && url): + result = `${baseURL.replace(/\/$/, '')}/${url.replace(/^\//, '')}`; + break; + case Boolean(baseURL) && !url: + result = baseURL; + break; + case !baseURL && Boolean(url): + result = url; + break; + case !baseURL && !url: + default: + result = '[empty]'; + break; + } + + return result; +} diff --git a/src/preset/isomorphic/utils/fetch-logging.ts b/src/preset/isomorphic/utils/fetch-logging.ts new file mode 100644 index 00000000..ba22b684 --- /dev/null +++ b/src/preset/isomorphic/utils/fetch-logging.ts @@ -0,0 +1,82 @@ +import { LogHandler, LogData, DoneLogData, FetchUtil, FailLogData } from '../../../http'; +import { Breadcrumb, DetailedError, Logger } from '../../../log'; +import { Disableable } from './disableable'; + +/** + * Обработчик логирования внешних http-запросов. + */ +export class FetchLogging extends Disableable implements LogHandler { + logger: Logger; + + /** @inheritdoc */ + constructor(logger: Logger) { + super(); + this.logger = logger; + } + + /** @inheritdoc */ + onRequest({ request }: LogData) { + if (this.isDisabled()) { + return; + } + + this.logger.info( + new Breadcrumb({ + category: 'http.request', + type: 'http', + data: { + url: FetchUtil.withoutParams(request.url).href, + method: request.method, + params: Object.fromEntries(new URL(request.url).searchParams.entries()), + }, + level: 'info', + }), + ); + } + + /** @inheritdoc */ + onResponse({ response, request }: DoneLogData) { + if (this.isDisabled()) { + return; + } + + this.logger.info( + new Breadcrumb({ + category: 'http.response', + type: 'http', + data: { + url: FetchUtil.withoutParams(request.url).href, + method: request.method, + status_code: response.status, + params: Object.fromEntries(new URL(request.url).searchParams.entries()), + }, + level: response.ok ? 'info' : 'error', + }), + ); + } + + /** @inheritdoc */ + onCatch({ error, request }: FailLogData) { + if (this.isDisabled()) { + return; + } + + this.logger.error( + new DetailedError(String(error), { + level: 'error', + context: [ + { + key: 'Outgoing request details', + data: { + url: FetchUtil.withoutParams(request.url).href, + method: request.method, + headers: request.headers, + params: Object.fromEntries(new URL(request.url).searchParams.entries()), + // @todo data + }, + }, + ], + }), + ); + } +} diff --git a/src/preset/isomorphic/utils/get-fetch-error-logging.ts b/src/preset/isomorphic/utils/get-fetch-error-logging.ts new file mode 100644 index 00000000..82481d09 --- /dev/null +++ b/src/preset/isomorphic/utils/get-fetch-error-logging.ts @@ -0,0 +1,17 @@ +import { log, type LogHandler, type LogHandlerFactory, type Middleware } from '../../../http'; + +/** + * Возвращает новый промежуточный слой логирования ошибки исходящего запроса. + * @param handlerInit Обработчик. + * @return Промежуточный слой. + */ +export function getFetchErrorLogging(handlerInit: LogHandler | LogHandlerFactory): Middleware { + const getHandler: LogHandlerFactory = + typeof handlerInit === 'function' ? handlerInit : () => handlerInit; + + return log({ + onCatch: data => { + getHandler(data).onCatch?.(data); + }, + }); +} diff --git a/src/preset/isomorphic/utils/get-fetch-extra-aborting.ts b/src/preset/isomorphic/utils/get-fetch-extra-aborting.ts new file mode 100644 index 00000000..a6147b5e --- /dev/null +++ b/src/preset/isomorphic/utils/get-fetch-extra-aborting.ts @@ -0,0 +1,31 @@ +import type { Middleware } from '../../../http'; + +/** + * Возвращает новый промежуточный слой обрывания по заданному контроллеру. + * Учитывает передачу контроллера в запросе. + * @param controller Контроллер. + * @return Промежуточный слой. + */ +export function getFetchExtraAborting(controller: AbortController): Middleware { + return (request, next) => { + const innerController = new AbortController(); + + request.signal?.addEventListener( + 'abort', + () => { + innerController.abort(); + }, + { once: true }, + ); + + controller.signal.addEventListener( + 'abort', + () => { + innerController.abort(); + }, + { once: true }, + ); + + return next(new Request(request, { signal: innerController.signal })); + }; +} diff --git a/src/preset/isomorphic/utils/get-fetch-logging.ts b/src/preset/isomorphic/utils/get-fetch-logging.ts new file mode 100644 index 00000000..524adfc0 --- /dev/null +++ b/src/preset/isomorphic/utils/get-fetch-logging.ts @@ -0,0 +1,20 @@ +import { log, type LogHandler, type LogHandlerFactory, type Middleware } from '../../../http'; + +/** + * Возвращает новый промежуточный слой логирования исходящего запроса и входящего ответа. + * @param handlerInit Обработчик. + * @return Промежуточный слой. + */ +export function getFetchLogging(handlerInit: LogHandler | LogHandlerFactory): Middleware { + const getHandler: LogHandlerFactory = + typeof handlerInit === 'function' ? handlerInit : () => handlerInit; + + return log({ + onRequest: data => { + getHandler(data).onRequest?.(data); + }, + onResponse: data => { + getHandler(data).onResponse?.(data); + }, + }); +} diff --git a/src/preset/isomorphic/utils/http-api-host-pool.ts b/src/preset/isomorphic/utils/http-api-host-pool.ts new file mode 100644 index 00000000..c4bb4aa7 --- /dev/null +++ b/src/preset/isomorphic/utils/http-api-host-pool.ts @@ -0,0 +1,45 @@ +import type { ConfigSource } from '../../../config'; +import type { StrictMap } from '../types'; + +/** Реализация пула хостов. */ +export class HttpApiHostPool implements StrictMap { + private map: Record; + private source: ConfigSource; + + /** + * Конструктор. + * @param map Карта "Название api >> Название переменной среды с хостом api". + * @param source Источник конфигурации. + */ + constructor(map: Record, source: ConfigSource) { + this.map = map; + this.source = source; + } + + /** @inheritDoc */ + get(key: Key, { absolute }: { absolute?: boolean } = {}): string { + const variableName = this.map[key]; + + if (!variableName) { + throw Error(`Known HTTP API not found by key "${key}"`); + } + + // "лениво" берём переменную, именно в момент вызова (чтобы не заставлять указывать в сервисах все переменные разом) + const value = this.source.require(variableName); + + if (absolute) { + return new Request(value).url; + } + + return value; + } + + /** + * Возвращает объект в котором ключи - переданные имена хостов а значения - хосты. + * @param keys Названия хостов. + * @return Объект. + */ + getAll(keys: Key[] = Object.keys(this.map) as Key[]): Record { + return Object.fromEntries(keys.map(key => [key, this.get(key)])) as Record; + } +} diff --git a/src/preset/isomorphic/utils/http-status.ts b/src/preset/isomorphic/utils/http-status.ts new file mode 100644 index 00000000..289ec29c --- /dev/null +++ b/src/preset/isomorphic/utils/http-status.ts @@ -0,0 +1,64 @@ +import type { AxiosRequestConfig } from 'axios'; +import type { Middleware as AxiosMiddleware } from 'middleware-axios'; + +/** Работа с HTTP-статусами по соглашению. */ +export abstract class HttpStatus { + /** + * Определяет, является ли переданный статус успешным. + * @param status Статус. + * @return Признак. + */ + static isOk(status: unknown): boolean { + return typeof status === 'number' && status === 200; + } + + /** + * Определяет, является ли переданный статус успешным POST. + * @param status Статус. + * @return Признак. + */ + static isPostOk(status: unknown): boolean { + return typeof status === 'number' && status === 201; + } + + /** + * Определяет, является ли переданный статус успешным DELETE. + * @param status Статус. + * @return Признак. + */ + static isDeleteOk(status: unknown): boolean { + return typeof status === 'number' && (status === 204 || status === 200); + } + + /** + * Возвращает новый промежуточный слой для валидации статуса HTTP-ответа. + * Валидация применяется только если в конфиге запроса не указан validateStatus. + * @return Промежуточный слой. + */ + static axiosMiddleware(): AxiosMiddleware { + return async (config, next, defaults) => { + if (config.validateStatus !== undefined || defaults.validateStatus !== undefined) { + // если validateStatus указан явно то не применяем валидацию по умолчанию + await next(config); + } else { + let validateStatus: AxiosRequestConfig['validateStatus'] = null; + + switch (config.method?.toLowerCase()) { + case 'get': + case 'put': + case undefined: + validateStatus = HttpStatus.isOk; + break; + case 'post': + validateStatus = HttpStatus.isPostOk; + break; + case 'delete': + validateStatus = HttpStatus.isDeleteOk; + break; + } + + await next({ ...config, validateStatus }); + } + }; + } +} diff --git a/src/preset/isomorphic/utils/index.ts b/src/preset/isomorphic/utils/index.ts deleted file mode 100644 index d1330c6f..00000000 --- a/src/preset/isomorphic/utils/index.ts +++ /dev/null @@ -1,559 +0,0 @@ -import { SeverityLevel } from '@sentry/browser'; -import Axios, { AxiosRequestConfig } from 'axios'; -import type { Middleware as AxiosMiddleware } from 'middleware-axios'; -import type { StrictMap } from '../types'; -import type { ConfigSource } from '../../../config/types'; -import { Logger, Breadcrumb, DetailedError } from '../../../log'; -import { - SharedData, - DoneSharedData, - FailSharedData, - LogMiddlewareHandler, -} from '../../../utils/axios/middleware/log'; -import { applyAxiosDefaults } from '../../../utils/axios/utils'; -import { - SagaErrorInfo, - SagaInterruptInfo, - SagaMiddlewareHandler, -} from '../../../utils/redux-saga/types'; -import { - DoneLogData, - FailLogData, - FetchUtil, - LogData, - LogHandler, - LogHandlerFactory, - Middleware, - log, -} from '../../../http'; - -/** Реализация пула хостов. */ -export class HttpApiHostPool implements StrictMap { - private map: Record; - private source: ConfigSource; - - /** - * Конструктор. - * @param map Карта "Название api >> Название переменной среды с хостом api". - * @param source Источник конфигурации. - */ - constructor(map: Record, source: ConfigSource) { - this.map = map; - this.source = source; - } - - /** @inheritDoc */ - get(key: Key, { absolute }: { absolute?: boolean } = {}): string { - const variableName = this.map[key]; - - if (!variableName) { - throw Error(`Known HTTP API not found by key "${key}"`); - } - - // "лениво" берём переменную, именно в момент вызова (чтобы не заставлять указывать в сервисах все переменные разом) - const value = this.source.require(variableName); - - if (absolute) { - return new Request(value).url; - } - - return value; - } - - /** - * Возвращает объект в котором ключи - переданные имена хостов а значения - хосты. - * @param keys Названия хостов. - * @return Объект. - */ - getAll(keys: Key[] = Object.keys(this.map) as Key[]): Record { - return Object.fromEntries(keys.map(key => [key, this.get(key)])) as Record; - } -} - -/** - * Возвращает уровень на основе статуса ответа. - * @param status Статус HTTP-ответа. - * @return Уровень. - */ -export function severityFromStatus(status: unknown): SeverityLevel { - let result: SeverityLevel; - - if (typeof status === 'number') { - switch (true) { - case status >= 200 && status <= 299: - result = 'info'; - break; - case status >= 300 && status <= 499: - result = 'warning'; - break; - default: - result = 'error'; - } - } else { - result = 'error'; - } - - return result; -} - -/** - * Возвращает новый промежуточный слой логирования исходящего запроса и входящего ответа. - * @param handlerInit Обработчик. - * @return Промежуточный слой. - */ -export function getFetchLogging(handlerInit: LogHandler | LogHandlerFactory): Middleware { - const getHandler: LogHandlerFactory = - typeof handlerInit === 'function' ? handlerInit : () => handlerInit; - - return log({ - onRequest: data => { - getHandler(data).onRequest?.(data); - }, - onResponse: data => { - getHandler(data).onResponse?.(data); - }, - }); -} - -/** - * Возвращает новый промежуточный слой логирования ошибки исходящего запроса. - * @param handlerInit Обработчик. - * @return Промежуточный слой. - */ -export function getFetchErrorLogging(handlerInit: LogHandler | LogHandlerFactory): Middleware { - const getHandler: LogHandlerFactory = - typeof handlerInit === 'function' ? handlerInit : () => handlerInit; - - return log({ - onCatch: data => { - getHandler(data).onCatch?.(data); - }, - }); -} - -/** - * Возвращает новый промежуточный слой обрывания по заданному контроллеру. - * Учитывает передачу контроллера в запросе. - * @param controller Контроллер. - * @return Промежуточный слой. - */ -export function getFetchExtraAborting(controller: AbortController): Middleware { - return (request, next) => { - const innerController = new AbortController(); - - request.signal?.addEventListener( - 'abort', - () => { - innerController.abort(); - }, - { once: true }, - ); - - controller.signal.addEventListener( - 'abort', - () => { - innerController.abort(); - }, - { once: true }, - ); - - return next(new Request(request, { signal: innerController.signal })); - }; -} - -/** - * Объект, который может быть помечен как disabled. - * @todo Возможно стоит заменить наследование от этого класса на передачу параметра в конструктор. - * Например в виде объекта класса DisableController (по аналогии с AbortController). - * Чтобы нельзя было включить обработчик в том месте где хочется. - * Также возможно стоит просто научить классы принимать AbortController. - */ -export class Disablable { - disabled: boolean | (() => boolean); - - /** @inheritdoc */ - constructor() { - this.disabled = false; - } - - /** - * Определяет отключен ли обработчик. - * @return Отключен ли обработчик. - */ - protected isDisabled() { - if (typeof this.disabled === 'function') { - return this.disabled(); - } - - return this.disabled; - } -} - -/** - * Обработчик логирования внешних http-запросов. - */ -export class FetchLogging extends Disablable implements LogHandler { - logger: Logger; - - /** @inheritdoc */ - constructor(logger: Logger) { - super(); - this.logger = logger; - } - - /** @inheritdoc */ - onRequest({ request }: LogData) { - if (this.isDisabled()) { - return; - } - - this.logger.info( - new Breadcrumb({ - category: 'http.request', - type: 'http', - data: { - url: FetchUtil.withoutParams(request.url).href, - method: request.method, - params: Object.fromEntries(new URL(request.url).searchParams.entries()), - }, - level: 'info', - }), - ); - } - - /** @inheritdoc */ - onResponse({ response, request }: DoneLogData) { - if (this.isDisabled()) { - return; - } - - this.logger.info( - new Breadcrumb({ - category: 'http.response', - type: 'http', - data: { - url: FetchUtil.withoutParams(request.url).href, - method: request.method, - status_code: response.status, - params: Object.fromEntries(new URL(request.url).searchParams.entries()), - }, - level: response.ok ? 'info' : 'error', - }), - ); - } - - /** @inheritdoc */ - onCatch({ error, request }: FailLogData) { - if (this.isDisabled()) { - return; - } - - this.logger.error( - new DetailedError(String(error), { - level: 'error', - context: [ - { - key: 'Outgoing request details', - data: { - url: FetchUtil.withoutParams(request.url).href, - method: request.method, - headers: request.headers, - params: Object.fromEntries(new URL(request.url).searchParams.entries()), - // @todo data - }, - }, - ], - }), - ); - } -} - -/** - * Обработчик для промежуточного слоя логирования исходящих http-запросов. - * Отправляет хлебные крошки и данные ошибки, пригодные для Sentry. - */ -export class AxiosLogging extends Disablable implements LogMiddlewareHandler { - logger: Logger; - - protected readonly requestInfo: ReturnType & { - readyURL: string; - }; - - /** - * Конструктор. - * @param logger Logger. - * @param data Данные запроса. - */ - constructor(logger: Logger, data: SharedData) { - super(); - const config = applyAxiosDefaults(data.config, data.defaults); - - this.logger = logger; - - this.requestInfo = { - ...config, - readyURL: displayUrl(config.baseURL, config.url), - }; - } - - /** - * Отправит хлебные крошки перед запросом. - */ - beforeRequest() { - if (this.isDisabled()) { - return; - } - - const { readyURL, method, params } = this.requestInfo; - - this.logger.info( - new Breadcrumb({ - category: 'http.request', - type: 'http', - data: { - url: readyURL, - method, - params, - }, - level: 'info', - }), - ); - } - - /** - * Отправит хлебные крошки после запроса. - * @param data Данные ответа. - */ - afterResponse({ response }: DoneSharedData) { - if (this.isDisabled()) { - return; - } - - const { readyURL, method, params } = this.requestInfo; - - this.logger.info( - new Breadcrumb({ - category: 'http.response', - type: 'http', - data: { - url: readyURL, - method, - status_code: response.status, - params, - }, - level: 'info', - }), - ); - } - - /** - * Отправит данные ошибки при перехвате. - * @param data Данные запроса. - */ - onCatch({ error }: FailSharedData) { - if (this.isDisabled()) { - return; - } - - if (Axios.isAxiosError(error)) { - const { requestInfo } = this; - const statusCode = error.response?.status || 'UNKNOWN'; - - // @todo выяснить: нужно ли нам отправлять ответы с кодом <500 в Sentry на уровне всех команд - // если да то можно добавить метод в духе errorStatusFilter(s => s !== 422) - this.logger.error( - new DetailedError( - `HTTP request failed, status code: ${statusCode}, error message: ${error.message}`, - { - level: severityFromStatus(error.response?.status), - context: [ - { - key: 'Request details', - data: { - url: requestInfo.url, - baseURL: requestInfo.baseURL, - method: requestInfo.method, - headers: requestInfo.headers, - data: requestInfo.data, - params: requestInfo.params, - }, - }, - { - key: 'Response details', - data: { - data: error.response?.data, - - // копируем так как в Sentry падает ошибка: **non-serializable** (TypeError: Object.getPrototypeOf(...) is null) - headers: { ...error.response?.headers }, - - error: error.toJSON(), - }, - }, - ], - }, - ), - ); - - if (typeof statusCode === 'number') { - this.logger.info( - new Breadcrumb({ - category: 'http.response', - type: 'http', - data: { - url: requestInfo.readyURL, - method: requestInfo.method, - status_code: statusCode, - params: requestInfo.params, - }, - level: 'error', - }), - ); - } - } else { - this.logger.error(error); - } - } -} - -/** - * Лог событий запуска и выполнения redux-saga. - */ -export class SagaLogging implements SagaMiddlewareHandler { - protected logger: Logger; - - /** - * @param logger Logger. - */ - constructor(logger: Logger) { - this.logger = logger; - } - - /** - * При получении ошибки выполнения саги передаст ее логгеру ее с данными стека в extra. - * @param error Ошибка. - * @param info Инфо выполнения саги. - */ - onSagaError(error: Error, info: SagaErrorInfo) { - this.logger.error( - new DetailedError(error.message, { - extra: { - key: 'Saga stack', - data: info.sagaStack, - }, - }), - ); - } - - /** - * При получении ошибки запуска саги передаст ее логгеру. - * @param error Ошибка. - */ - onConfigError(error: Error) { - this.logger.error(error); - } - - /** - * При прерывании саги передаст информацию логгеру как ошибку. - * @param info Инфо прерывания саги. - */ - onTimeoutInterrupt({ timeout }: SagaInterruptInfo) { - this.logger.error( - new DetailedError(`Сага прервана по таймауту (${timeout}мс)`, { - level: 'warning', - }), - ); - } -} - -/** Работа с HTTP-статусами по соглашению. */ -export abstract class HttpStatus { - /** - * Определяет, является ли переданный статус успешным. - * @param status Статус. - * @return Признак. - */ - static isOk(status: unknown): boolean { - return typeof status === 'number' && status === 200; - } - - /** - * Определяет, является ли переданный статус успешным POST. - * @param status Статус. - * @return Признак. - */ - static isPostOk(status: unknown): boolean { - return typeof status === 'number' && status === 201; - } - - /** - * Определяет, является ли переданный статус успешным DELETE. - * @param status Статус. - * @return Признак. - */ - static isDeleteOk(status: unknown): boolean { - return typeof status === 'number' && (status === 204 || status === 200); - } - - /** - * Возвращает новый промежуточный слой для валидации статуса HTTP-ответа. - * Валидация применяется только если в конфиге запроса не указан validateStatus. - * @return Промежуточный слой. - */ - static axiosMiddleware(): AxiosMiddleware { - return async (config, next, defaults) => { - if (config.validateStatus !== undefined || defaults.validateStatus !== undefined) { - // если validateStatus указан явно то не применяем валидацию по умолчанию - await next(config); - } else { - let validateStatus: AxiosRequestConfig['validateStatus'] = null; - - switch (config.method?.toLowerCase()) { - case 'get': - case 'put': - case undefined: - validateStatus = HttpStatus.isOk; - break; - case 'post': - validateStatus = HttpStatus.isPostOk; - break; - case 'delete': - validateStatus = HttpStatus.isDeleteOk; - break; - } - - await next({ ...config, validateStatus }); - } - }; - } -} - -/** - * Объединяет значения опций baseURL и url (axios) в одну строку для логирования. - * @param baseURL Опция baseURL. - * @param url Опция url. - * @return Отображение. Не является валидным URL. - */ -export function displayUrl( - baseURL: AxiosRequestConfig['baseURL'] = '', - url: AxiosRequestConfig['url'] = '', -) { - let result: string; - - switch (true) { - case Boolean(baseURL && url): - result = `${baseURL.replace(/\/$/, '')}/${url.replace(/^\//, '')}`; - break; - case Boolean(baseURL) && !url: - result = baseURL; - break; - case !baseURL && Boolean(url): - result = url; - break; - case !baseURL && !url: - default: - result = '[empty]'; - break; - } - - return result; -} diff --git a/src/preset/isomorphic/utils/saga-logging.ts b/src/preset/isomorphic/utils/saga-logging.ts new file mode 100644 index 00000000..921811ca --- /dev/null +++ b/src/preset/isomorphic/utils/saga-logging.ts @@ -0,0 +1,53 @@ +import { DetailedError, type Logger } from '../../../log'; +import type { SagaMiddlewareHandler } from '../../../utils/redux-saga'; +import type { SagaErrorInfo, SagaInterruptInfo } from '../../../utils/redux-saga/types'; + +/** + * Лог событий запуска и выполнения redux-saga. + */ +export class SagaLogging implements SagaMiddlewareHandler { + protected logger: Logger; + + /** + * @param logger Logger. + */ + constructor(logger: Logger) { + this.logger = logger; + } + + /** + * При получении ошибки выполнения саги передаст ее логгеру ее с данными стека в extra. + * @param error Ошибка. + * @param info Инфо выполнения саги. + */ + onSagaError(error: Error, info: SagaErrorInfo) { + this.logger.error( + new DetailedError(error.message, { + extra: { + key: 'Saga stack', + data: info.sagaStack, + }, + }), + ); + } + + /** + * При получении ошибки запуска саги передаст ее логгеру. + * @param error Ошибка. + */ + onConfigError(error: Error) { + this.logger.error(error); + } + + /** + * При прерывании саги передаст информацию логгеру как ошибку. + * @param info Инфо прерывания саги. + */ + onTimeoutInterrupt({ timeout }: SagaInterruptInfo) { + this.logger.error( + new DetailedError(`Сага прервана по таймауту (${timeout}мс)`, { + level: 'warning', + }), + ); + } +} diff --git a/src/preset/isomorphic/utils/severity-from-status.ts b/src/preset/isomorphic/utils/severity-from-status.ts new file mode 100644 index 00000000..2708eb9c --- /dev/null +++ b/src/preset/isomorphic/utils/severity-from-status.ts @@ -0,0 +1,27 @@ +import type { SeverityLevel } from '@sentry/browser'; + +/** + * Возвращает уровень на основе статуса ответа. + * @param status Статус HTTP-ответа. + * @return Уровень. + */ +export function severityFromStatus(status: unknown): SeverityLevel { + let result: SeverityLevel; + + if (typeof status === 'number') { + switch (true) { + case status >= 200 && status <= 299: + result = 'info'; + break; + case status >= 300 && status <= 499: + result = 'warning'; + break; + default: + result = 'error'; + } + } else { + result = 'error'; + } + + return result; +} diff --git a/src/preset/node/handler/providers/index.tsx b/src/preset/node/handler/providers/index.tsx index fb5ab8d3..78261c66 100644 --- a/src/preset/node/handler/providers/index.tsx +++ b/src/preset/node/handler/providers/index.tsx @@ -14,14 +14,12 @@ import { axiosTracingMiddleware, } from '../../node/utils/http-client'; import type { Middleware as AxiosMiddleware } from 'middleware-axios'; -import { - AxiosLogging, - FetchLogging, - HttpStatus, - getFetchErrorLogging, - getFetchExtraAborting, - getFetchLogging, -} from '../../../isomorphic/utils'; +import { AxiosLogging } from '../../../isomorphic/utils/axios-logging'; +import { FetchLogging } from '../../../isomorphic/utils/fetch-logging'; +import { HttpStatus } from '../../../isomorphic/utils/http-status'; +import { getFetchLogging } from '../../../isomorphic/utils/get-fetch-logging'; +import { getFetchErrorLogging } from '../../../isomorphic/utils/get-fetch-error-logging'; +import { getFetchExtraAborting } from '../../../isomorphic/utils/get-fetch-extra-aborting'; import { LogMiddlewareHandlerInit, cookieMiddleware, logMiddleware } from '../../../../utils/axios'; import { RESPONSE_EVENT_TYPE } from '../../../isomorphic/constants'; import type { ConventionalJson } from '../../../isomorphic/types'; diff --git a/src/preset/node/node/providers/index.ts b/src/preset/node/node/providers/index.ts index 80038449..27d39877 100644 --- a/src/preset/node/node/providers/index.ts +++ b/src/preset/node/node/providers/index.ts @@ -4,7 +4,7 @@ import { ConfigSource, createConfigSource } from '../../../../config'; import { createLogger } from '../../../../log'; import { createPinoHandler } from '../../../../log/handler/pino'; import { createSentryHandler } from '../../../../log/handler/sentry'; -import { HttpApiHostPool } from '../../../isomorphic/utils'; +import { HttpApiHostPool } from '../../../isomorphic/utils/http-api-host-pool'; import { KnownToken } from '../../../../tokens'; import { Resolve } from '../../../../di'; import { KnownHttpApiKey } from '../../../isomorphic/types'; diff --git a/src/preset/node/node/utils/http-client/index.ts b/src/preset/node/node/utils/http-client/index.ts index 01f6471b..ff4d8c67 100644 --- a/src/preset/node/node/utils/http-client/index.ts +++ b/src/preset/node/node/utils/http-client/index.ts @@ -5,7 +5,7 @@ import { Context, Tracer, SpanStatusCode } from '@opentelemetry/api'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { BaseConfig } from '../../../../../config'; import { getClientIp } from '../http-server'; -import { displayUrl } from '../../../../isomorphic/utils'; +import { displayUrl } from '../../../../isomorphic/utils/display-url'; import { hideFirstId } from '../../../../isomorphic/utils/hide-first-id'; /** diff --git a/src/preset/web/providers/index.ts b/src/preset/web/providers/index.ts index 144f4f5d..f5bb9963 100644 --- a/src/preset/web/providers/index.ts +++ b/src/preset/web/providers/index.ts @@ -12,7 +12,9 @@ import { KnownToken } from '../../../tokens'; import { createSentryHandler } from '../../../log/handler/sentry'; import { BridgeClientSide, SsrBridge } from '../../../utils/ssr'; import { KnownHttpApiKey } from '../../isomorphic/types'; -import { FetchLogging, HttpApiHostPool, HttpStatus } from '../../isomorphic/utils'; +import { FetchLogging } from '../../isomorphic/utils/fetch-logging'; +import { HttpApiHostPool } from '../../isomorphic/utils/http-api-host-pool'; +import { HttpStatus } from '../../isomorphic/utils/http-status'; import { logMiddleware } from '../../../utils/axios'; import { log } from '../../../http'; diff --git a/src/tokens.ts b/src/tokens.ts index 62394ba0..00426132 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -18,7 +18,7 @@ import type { SpecificExtras } from './preset/node/handler/utils'; import type { CreateAxiosDefaults } from 'axios'; import type { AxiosInstanceWrapper, Middleware as AxiosMiddleware } from 'middleware-axios'; import type { CookieStore, Handler, LogHandler, LogHandlerFactory, Middleware } from './http'; -import type { HttpApiHostPool } from './preset/isomorphic/utils'; +import type { HttpApiHostPool } from './preset/isomorphic/utils/http-api-host-pool'; import type { ServerHandlerContext, ServerHandler, ServerMiddleware } from './preset/server/types'; export const KnownToken = { diff --git a/src/utils/axios/sauce/__test__/index.test.ts b/src/utils/axios/sauce/__test__/index.test.ts new file mode 100644 index 00000000..80c237ae --- /dev/null +++ b/src/utils/axios/sauce/__test__/index.test.ts @@ -0,0 +1,63 @@ +import axios from 'axios'; +import { sauce, formatResultInfo } from '..'; + +describe('sauce', () => { + it('should return axios like object', () => { + const result = sauce(axios.create()); + + expect(typeof result.request === 'function').toBe(true); + expect(typeof result.get === 'function').toBe(true); + expect(typeof result.delete === 'function').toBe(true); + expect(typeof result.head === 'function').toBe(true); + expect(typeof result.options === 'function').toBe(true); + expect(typeof result.post === 'function').toBe(true); + expect(typeof result.put === 'function').toBe(true); + expect(typeof result.patch === 'function').toBe(true); + }); +}); + +describe('formatResultInfo', () => { + it('should return apisauce like formatted object', () => { + const success = formatResultInfo({ + ok: true, + result: { + data: { foo: 'bar' }, + status: 201, + statusText: 'CREATED', + config: {} as any, + headers: {}, + }, + }); + + expect(success.ok).toBe(true); + expect(success.data).toEqual({ foo: 'bar' }); + }); + + it('should return apisauce like formatted object when failed', () => { + const error = new axios.AxiosError('MSG', '500', {} as any, {}, { + data: { bar: 'baz' }, + } as any); + + const failure = formatResultInfo({ + ok: false, + error, + }) as any; + + expect(failure.ok).toBe(false); + expect(failure.data).toEqual({ bar: 'baz' }); + expect(failure.error).toBe(error); + }); + + it('should return apisauce like formatted object when failed with non axios error', () => { + const error = new Error('NON AXIOS ERROR'); + + const failure = formatResultInfo({ + ok: false, + error, + }) as any; + + expect(failure.ok).toBe(false); + expect(failure.data).toBe(undefined); + expect(failure.error).toBe(error); + }); +});