diff --git a/src/core/public/http/fetch.test.ts b/src/core/public/http/fetch.test.ts index efd9fdd053674..f223956075e97 100644 --- a/src/core/public/http/fetch.test.ts +++ b/src/core/public/http/fetch.test.ts @@ -21,6 +21,7 @@ import fetchMock from 'fetch-mock/es5/client'; import { readFileSync } from 'fs'; import { join } from 'path'; +import { first } from 'rxjs/operators'; import { Fetch } from './fetch'; import { BasePath } from './base_path'; @@ -30,9 +31,11 @@ function delay(duration: number) { return new Promise(r => setTimeout(r, duration)); } +const BASE_PATH = 'http://localhost/myBase'; + describe('Fetch', () => { const fetchInstance = new Fetch({ - basePath: new BasePath('http://localhost/myBase'), + basePath: new BasePath(BASE_PATH), kibanaVersion: 'VERSION', }); afterEach(() => { @@ -40,6 +43,79 @@ describe('Fetch', () => { fetchInstance.removeAllInterceptors(); }); + describe('getRequestCount$', () => { + const getCurrentRequestCount = () => + fetchInstance + .getRequestCount$() + .pipe(first()) + .toPromise(); + + it('should increase and decrease when request receives success response', async () => { + fetchMock.get('*', 200); + + const fetchResponse = fetchInstance.fetch('/path'); + expect(await getCurrentRequestCount()).toEqual(1); + + await expect(fetchResponse).resolves.not.toThrow(); + expect(await getCurrentRequestCount()).toEqual(0); + }); + + it('should increase and decrease when request receives error response', async () => { + fetchMock.get('*', 500); + + const fetchResponse = fetchInstance.fetch('/path'); + expect(await getCurrentRequestCount()).toEqual(1); + + await expect(fetchResponse).rejects.toThrow(); + expect(await getCurrentRequestCount()).toEqual(0); + }); + + it('should increase and decrease when request fails', async () => { + fetchMock.get('*', Promise.reject('Network!')); + + const fetchResponse = fetchInstance.fetch('/path'); + expect(await getCurrentRequestCount()).toEqual(1); + + await expect(fetchResponse).rejects.toThrow(); + expect(await getCurrentRequestCount()).toEqual(0); + }); + + it('should change for multiple requests', async () => { + fetchMock.get(`${BASE_PATH}/success`, 200); + fetchMock.get(`${BASE_PATH}/fail`, 400); + fetchMock.get(`${BASE_PATH}/network-fail`, Promise.reject('Network!')); + + const requestCounts: number[] = []; + const subscription = fetchInstance + .getRequestCount$() + .subscribe(count => requestCounts.push(count)); + + const success1 = fetchInstance.fetch('/success'); + const success2 = fetchInstance.fetch('/success'); + const failure1 = fetchInstance.fetch('/fail'); + const failure2 = fetchInstance.fetch('/fail'); + const networkFailure1 = fetchInstance.fetch('/network-fail'); + const success3 = fetchInstance.fetch('/success'); + const failure3 = fetchInstance.fetch('/fail'); + const networkFailure2 = fetchInstance.fetch('/network-fail'); + + const swallowError = (p: Promise) => p.catch(() => {}); + await Promise.all([ + success1, + success2, + success3, + swallowError(failure1), + swallowError(failure2), + swallowError(failure3), + swallowError(networkFailure1), + swallowError(networkFailure2), + ]); + + expect(requestCounts).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 7, 6, 5, 4, 3, 2, 1, 0]); + subscription.unsubscribe(); + }); + }); + describe('http requests', () => { it('should fail with invalid arguments', async () => { fetchMock.get('*', {}); diff --git a/src/core/public/http/fetch.ts b/src/core/public/http/fetch.ts index b433acdb6dbb9..d88dc2e3a9037 100644 --- a/src/core/public/http/fetch.ts +++ b/src/core/public/http/fetch.ts @@ -19,6 +19,7 @@ import { merge } from 'lodash'; import { format } from 'url'; +import { BehaviorSubject } from 'rxjs'; import { IBasePath, @@ -43,6 +44,7 @@ const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/; export class Fetch { private readonly interceptors = new Set(); + private readonly requestCount$ = new BehaviorSubject(0); constructor(private readonly params: Params) {} @@ -57,6 +59,10 @@ export class Fetch { this.interceptors.clear(); } + public getRequestCount$() { + return this.requestCount$.asObservable(); + } + public readonly delete = this.shorthand('DELETE'); public readonly get = this.shorthand('GET'); public readonly head = this.shorthand('HEAD'); @@ -76,6 +82,7 @@ export class Fetch { // a halt is called we do not resolve or reject, halting handling of the promise. return new Promise>(async (resolve, reject) => { try { + this.requestCount$.next(this.requestCount$.value + 1); const interceptedOptions = await interceptRequest( optionsWithPath, this.interceptors, @@ -98,6 +105,8 @@ export class Fetch { if (!(error instanceof HttpInterceptHaltError)) { reject(error); } + } finally { + this.requestCount$.next(this.requestCount$.value - 1); } }); }; diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index a40fcb06273dd..78220af9cc83b 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -24,6 +24,7 @@ import { loadingServiceMock } from './http_service.test.mocks'; import { fatalErrorsServiceMock } from '../fatal_errors/fatal_errors_service.mock'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { HttpService } from './http_service'; +import { Observable } from 'rxjs'; describe('interceptors', () => { afterEach(() => fetchMock.restore()); @@ -52,6 +53,18 @@ describe('interceptors', () => { }); }); +describe('#setup()', () => { + it('registers Fetch#getLoadingCount$() with LoadingCountSetup#addLoadingCountSource()', () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const httpService = new HttpService(); + httpService.setup({ fatalErrors, injectedMetadata }); + const loadingServiceSetup = loadingServiceMock.setup.mock.results[0].value; + // We don't verify that this Observable comes from Fetch#getLoadingCount$() to avoid complex mocking + expect(loadingServiceSetup.addLoadingCountSource).toHaveBeenCalledWith(expect.any(Observable)); + }); +}); + describe('#stop()', () => { it('calls loadingCount.stop()', () => { const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index 44fc9d65565d4..98de1d919c481 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -45,6 +45,7 @@ export class HttpService implements CoreService { ); const fetchService = new Fetch({ basePath, kibanaVersion }); const loadingCount = this.loadingCount.setup({ fatalErrors }); + loadingCount.addLoadingCountSource(fetchService.getRequestCount$()); this.service = { basePath,