From 1b096f3b7335f0ad575fb48d71c9091e10f0fcde Mon Sep 17 00:00:00 2001 From: Eli Perelman Date: Fri, 31 May 2019 12:56:47 -0500 Subject: [PATCH] Allow interception of http requests from browser http service (#36939) * Allow interception of http requests from browser http service * Update documentation and browser http types * Remove async marker from fetch function * Fix failing tests * Attempting to fix kuery_autocomplete test * Allow halting of http fetches from interception * Re-use HttpInterceptHaltError * Expose HttpInterceptor types and update docs * Only mock calls to capabilities during browser testing --- .../kibana-plugin-public.httpinterceptor.md | 22 ++ ...a-plugin-public.httpinterceptor.request.md | 23 ++ ...gin-public.httpinterceptor.requesterror.md | 23 ++ ...-plugin-public.httpinterceptor.response.md | 23 ++ ...in-public.httpinterceptor.responseerror.md | 23 ++ ...plugin-public.httpservicebase.intercept.md | 22 ++ .../kibana-plugin-public.httpservicebase.md | 2 + ...c.httpservicebase.removeallinterceptors.md | 15 + .../core/public/kibana-plugin-public.md | 1 + .../public/http/http_intercept_controller.ts | 30 ++ .../public/http/http_intercept_halt_error.ts | 30 ++ src/core/public/http/http_service.mock.ts | 2 + src/core/public/http/http_service.test.ts | 266 +++++++++++++++++- src/core/public/http/http_setup.ts | 138 ++++++++- src/core/public/http/index.ts | 4 +- src/core/public/http/types.ts | 40 ++- src/core/public/index.ts | 3 +- src/core/public/public.api.md | 24 ++ .../ui_settings_api.test.ts.snap | 48 +--- .../ui_settings_service.test.ts.snap | 2 + .../ui_settings/ui_settings_api.test.ts | 8 +- .../public/ui_settings/ui_settings_api.ts | 7 + .../tests_bundle/tests_entry_template.js | 24 +- src/legacy/ui/public/kfetch/kfetch.test.ts | 18 +- .../autocomplete_providers/__tests__/value.js | 8 +- 25 files changed, 719 insertions(+), 87 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.httpinterceptor.md create mode 100644 docs/development/core/public/kibana-plugin-public.httpinterceptor.request.md create mode 100644 docs/development/core/public/kibana-plugin-public.httpinterceptor.requesterror.md create mode 100644 docs/development/core/public/kibana-plugin-public.httpinterceptor.response.md create mode 100644 docs/development/core/public/kibana-plugin-public.httpinterceptor.responseerror.md create mode 100644 docs/development/core/public/kibana-plugin-public.httpservicebase.intercept.md create mode 100644 docs/development/core/public/kibana-plugin-public.httpservicebase.removeallinterceptors.md create mode 100644 src/core/public/http/http_intercept_controller.ts create mode 100644 src/core/public/http/http_intercept_halt_error.ts diff --git a/docs/development/core/public/kibana-plugin-public.httpinterceptor.md b/docs/development/core/public/kibana-plugin-public.httpinterceptor.md new file mode 100644 index 000000000000..78872e71f02b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpinterceptor.md @@ -0,0 +1,22 @@ + + +[Home](./index) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) + +## HttpInterceptor interface + + +Signature: + +```typescript +export interface HttpInterceptor +``` + +## Methods + +| Method | Description | +| --- | --- | +| [request(request, controller)](./kibana-plugin-public.httpinterceptor.request.md) | | +| [requestError(httpErrorRequest, controller)](./kibana-plugin-public.httpinterceptor.requesterror.md) | | +| [response(httpResponse, controller)](./kibana-plugin-public.httpinterceptor.response.md) | | +| [responseError(httpErrorResponse, controller)](./kibana-plugin-public.httpinterceptor.responseerror.md) | | + diff --git a/docs/development/core/public/kibana-plugin-public.httpinterceptor.request.md b/docs/development/core/public/kibana-plugin-public.httpinterceptor.request.md new file mode 100644 index 000000000000..aa2cdab8bad0 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpinterceptor.request.md @@ -0,0 +1,23 @@ + + +[Home](./index) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) > [request](./kibana-plugin-public.httpinterceptor.request.md) + +## HttpInterceptor.request() method + +Signature: + +```typescript +request?(request: Request, controller: HttpInterceptController): Promise | Request | void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| request | Request | | +| controller | HttpInterceptController | | + +Returns: + +`Promise | Request | void` + diff --git a/docs/development/core/public/kibana-plugin-public.httpinterceptor.requesterror.md b/docs/development/core/public/kibana-plugin-public.httpinterceptor.requesterror.md new file mode 100644 index 000000000000..00377ba6cbfb --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpinterceptor.requesterror.md @@ -0,0 +1,23 @@ + + +[Home](./index) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) > [requestError](./kibana-plugin-public.httpinterceptor.requesterror.md) + +## HttpInterceptor.requestError() method + +Signature: + +```typescript +requestError?(httpErrorRequest: HttpErrorRequest, controller: HttpInterceptController): Promise | Request | void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| httpErrorRequest | HttpErrorRequest | | +| controller | HttpInterceptController | | + +Returns: + +`Promise | Request | void` + diff --git a/docs/development/core/public/kibana-plugin-public.httpinterceptor.response.md b/docs/development/core/public/kibana-plugin-public.httpinterceptor.response.md new file mode 100644 index 000000000000..5cc703c4c319 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpinterceptor.response.md @@ -0,0 +1,23 @@ + + +[Home](./index) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) > [response](./kibana-plugin-public.httpinterceptor.response.md) + +## HttpInterceptor.response() method + +Signature: + +```typescript +response?(httpResponse: HttpResponse, controller: HttpInterceptController): Promise | HttpResponse | void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| httpResponse | HttpResponse | | +| controller | HttpInterceptController | | + +Returns: + +`Promise | HttpResponse | void` + diff --git a/docs/development/core/public/kibana-plugin-public.httpinterceptor.responseerror.md b/docs/development/core/public/kibana-plugin-public.httpinterceptor.responseerror.md new file mode 100644 index 000000000000..ff1b369e8725 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpinterceptor.responseerror.md @@ -0,0 +1,23 @@ + + +[Home](./index) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) > [responseError](./kibana-plugin-public.httpinterceptor.responseerror.md) + +## HttpInterceptor.responseError() method + +Signature: + +```typescript +responseError?(httpErrorResponse: HttpErrorResponse, controller: HttpInterceptController): Promise | HttpResponse | void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| httpErrorResponse | HttpErrorResponse | | +| controller | HttpInterceptController | | + +Returns: + +`Promise | HttpResponse | void` + diff --git a/docs/development/core/public/kibana-plugin-public.httpservicebase.intercept.md b/docs/development/core/public/kibana-plugin-public.httpservicebase.intercept.md new file mode 100644 index 000000000000..59daed27ff17 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpservicebase.intercept.md @@ -0,0 +1,22 @@ + + +[Home](./index) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) > [intercept](./kibana-plugin-public.httpservicebase.intercept.md) + +## HttpServiceBase.intercept() method + +Signature: + +```typescript +intercept(interceptor: HttpInterceptor): () => void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| interceptor | HttpInterceptor | | + +Returns: + +`() => void` + diff --git a/docs/development/core/public/kibana-plugin-public.httpservicebase.md b/docs/development/core/public/kibana-plugin-public.httpservicebase.md index bcc76a1a3b6e..c84d48a521db 100644 --- a/docs/development/core/public/kibana-plugin-public.httpservicebase.md +++ b/docs/development/core/public/kibana-plugin-public.httpservicebase.md @@ -31,7 +31,9 @@ export interface HttpServiceBase | [addLoadingCount(count$)](./kibana-plugin-public.httpservicebase.addloadingcount.md) | | | [getBasePath()](./kibana-plugin-public.httpservicebase.getbasepath.md) | | | [getLoadingCount$()](./kibana-plugin-public.httpservicebase.getloadingcount$.md) | | +| [intercept(interceptor)](./kibana-plugin-public.httpservicebase.intercept.md) | | | [prependBasePath(path)](./kibana-plugin-public.httpservicebase.prependbasepath.md) | | +| [removeAllInterceptors()](./kibana-plugin-public.httpservicebase.removeallinterceptors.md) | | | [removeBasePath(path)](./kibana-plugin-public.httpservicebase.removebasepath.md) | | | [stop()](./kibana-plugin-public.httpservicebase.stop.md) | | diff --git a/docs/development/core/public/kibana-plugin-public.httpservicebase.removeallinterceptors.md b/docs/development/core/public/kibana-plugin-public.httpservicebase.removeallinterceptors.md new file mode 100644 index 000000000000..31192431e856 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpservicebase.removeallinterceptors.md @@ -0,0 +1,15 @@ + + +[Home](./index) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) > [removeAllInterceptors](./kibana-plugin-public.httpservicebase.removeallinterceptors.md) + +## HttpServiceBase.removeAllInterceptors() method + +Signature: + +```typescript +removeAllInterceptors(): void; +``` +Returns: + +`void` + diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index 6ff9df487957..b38e9168c5de 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -33,6 +33,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | | | [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | +| [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | | | [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) | | | [I18nSetup](./kibana-plugin-public.i18nsetup.md) | I18nSetup.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | | [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | diff --git a/src/core/public/http/http_intercept_controller.ts b/src/core/public/http/http_intercept_controller.ts new file mode 100644 index 000000000000..65585e6ff7e2 --- /dev/null +++ b/src/core/public/http/http_intercept_controller.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export class HttpInterceptController { + private _halted = false; + + get halted() { + return this._halted; + } + + halt() { + this._halted = true; + } +} diff --git a/src/core/public/http/http_intercept_halt_error.ts b/src/core/public/http/http_intercept_halt_error.ts new file mode 100644 index 000000000000..856a912f63c8 --- /dev/null +++ b/src/core/public/http/http_intercept_halt_error.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export class HttpInterceptHaltError extends Error { + constructor() { + super('HTTP Intercept Halt'); + + // captureStackTrace is only available in the V8 engine, so any browser using + // a different JS engine won't have access to this method. + if (Error.captureStackTrace) { + Error.captureStackTrace(this, HttpInterceptHaltError); + } + } +} diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 2ca2a45d2c69..3f5611ba7f7d 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -35,6 +35,8 @@ const createServiceMock = () => ({ addLoadingCount: jest.fn(), getLoadingCount$: jest.fn(), stop: jest.fn(), + intercept: jest.fn(), + removeAllInterceptors: jest.fn(), }); const createSetupContractMock = (): jest.Mocked => createServiceMock(); diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index 962f0b732568..d40152157ec9 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -126,7 +126,7 @@ describe('http requests', () => { await http.fetch('/my/path', { headers: { 'Content-Type': 'CustomContentType' } }); expect(fetchMock.lastOptions()!.headers).toMatchObject({ - 'Content-Type': 'CustomContentType', + 'content-type': 'CustomContentType', }); }); @@ -148,9 +148,9 @@ describe('http requests', () => { }); expect(fetchMock.lastOptions()!.headers).toEqual({ - 'Content-Type': 'application/json', + 'content-type': 'application/json', 'kbn-version': 'kibanaVersion', - myHeader: 'foo', + myheader: 'foo', }); }); @@ -188,11 +188,13 @@ describe('http requests', () => { fetchMock.get('*', {}); await http.fetch('/my/path'); - expect(fetchMock.lastOptions()!).toMatchObject({ + const lastCall = fetchMock.lastCall(); + + expect(lastCall!.request.credentials).toBe('same-origin'); + expect(lastCall![1]).toMatchObject({ method: 'GET', - credentials: 'same-origin', headers: { - 'Content-Type': 'application/json', + 'content-type': 'application/json', 'kbn-version': 'kibanaVersion', }, }); @@ -313,6 +315,258 @@ describe('http requests', () => { }); }); +describe('interception', () => { + const { http } = setup(); + + beforeEach(() => { + fetchMock.get('*', { foo: 'bar' }); + }); + + afterEach(() => { + fetchMock.restore(); + http.removeAllInterceptors(); + }); + + it('should make request and receive response', async () => { + http.intercept({}); + + const body = await http.fetch('/my/path'); + + expect(fetchMock.called()).toBe(true); + expect(body).toEqual({ foo: 'bar' }); + }); + + it('should be able to manipulate request instance', async () => { + http.intercept({ + request(request) { + request.headers.set('Content-Type', 'CustomContentType'); + }, + }); + http.intercept({ + request(request) { + return new Request('/my/route', request); + }, + }); + + const body = await http.fetch('/my/path'); + + expect(fetchMock.called()).toBe(true); + expect(body).toEqual({ foo: 'bar' }); + expect(fetchMock.lastOptions()!.headers).toMatchObject({ + 'content-type': 'CustomContentType', + }); + expect(fetchMock.lastUrl()).toBe('/my/route'); + }); + + it('should call interceptors in correct order', async () => { + const order: string[] = []; + + http.intercept({ + request() { + order.push('Request 1'); + }, + response() { + order.push('Response 1'); + }, + }); + http.intercept({ + request() { + order.push('Request 2'); + }, + response() { + order.push('Response 2'); + }, + }); + http.intercept({ + request() { + order.push('Request 3'); + }, + response() { + order.push('Response 3'); + }, + }); + + const body = await http.fetch('/my/path'); + + expect(fetchMock.called()).toBe(true); + expect(body).toEqual({ foo: 'bar' }); + expect(order).toEqual([ + 'Request 3', + 'Request 2', + 'Request 1', + 'Response 1', + 'Response 2', + 'Response 3', + ]); + }); + + it('should skip remaining interceptors when controller halts during request', async () => { + const order: string[] = []; + + http.intercept({ + request() { + order.push('Request 1'); + }, + response() { + order.push('Response 1'); + }, + }); + http.intercept({ + request(request, controller) { + controller.halt(); + order.push('Request 2'); + }, + response() { + order.push('Response 2'); + }, + }); + http.intercept({ + request() { + order.push('Request 3'); + }, + response() { + order.push('Response 3'); + }, + }); + + await expect(http.fetch('/my/wat')).rejects.toThrow(/HTTP Intercept Halt/); + expect(fetchMock.called()).toBe(false); + expect(order).toEqual(['Request 3', 'Request 2']); + }); + + it('should skip remaining interceptors when controller halts during response', async () => { + const order: string[] = []; + + http.intercept({ + request() { + order.push('Request 1'); + }, + response(response, controller) { + controller.halt(); + order.push('Response 1'); + }, + }); + http.intercept({ + request() { + order.push('Request 2'); + }, + response() { + order.push('Response 2'); + }, + }); + http.intercept({ + request() { + order.push('Request 3'); + }, + response() { + order.push('Response 3'); + }, + }); + + await expect(http.fetch('/my/wat')).rejects.toThrow(/HTTP Intercept Halt/); + expect(fetchMock.called()).toBe(true); + expect(order).toEqual(['Request 3', 'Request 2', 'Request 1', 'Response 1']); + }); + + it('should not fetch if exception occurs during request interception', async () => { + const order: string[] = []; + + http.intercept({ + request() { + order.push('Request 1'); + }, + requestError() { + order.push('RequestError 1'); + }, + response() { + order.push('Response 1'); + }, + responseError() { + order.push('ResponseError 1'); + }, + }); + http.intercept({ + request() { + order.push('Request 2'); + throw new Error('Interception Error'); + }, + response() { + order.push('Response 2'); + }, + responseError() { + order.push('ResponseError 2'); + }, + }); + http.intercept({ + request() { + order.push('Request 3'); + }, + response() { + order.push('Response 3'); + }, + responseError() { + order.push('ResponseError 3'); + }, + }); + + await expect(http.fetch('/my/wat')).rejects.toThrow(/Interception Error/); + expect(fetchMock.called()).toBe(false); + expect(order).toEqual([ + 'Request 3', + 'Request 2', + 'RequestError 1', + 'ResponseError 1', + 'ResponseError 2', + 'ResponseError 3', + ]); + }); + + it('should succeed if request throws but caught by interceptor', async () => { + const order: string[] = []; + + http.intercept({ + request() { + order.push('Request 1'); + }, + requestError({ request }) { + order.push('RequestError 1'); + return new Request('/my/route', request); + }, + response() { + order.push('Response 1'); + }, + }); + http.intercept({ + request() { + order.push('Request 2'); + throw new Error('Interception Error'); + }, + response() { + order.push('Response 2'); + }, + }); + http.intercept({ + request() { + order.push('Request 3'); + }, + response() { + order.push('Response 3'); + }, + }); + + await expect(http.fetch('/my/route')).resolves.toEqual({ foo: 'bar' }); + expect(fetchMock.called()).toBe(true); + expect(order).toEqual([ + 'Request 3', + 'Request 2', + 'RequestError 1', + 'Response 1', + 'Response 2', + 'Response 3', + ]); + }); +}); + describe('addLoadingCount()', () => { it('subscribes to passed in sources, unsubscribes on stop', () => { const { httpService, http } = setup(); diff --git a/src/core/public/http/http_setup.ts b/src/core/public/http/http_setup.ts index a83676b8925a..c12ec4be5d3a 100644 --- a/src/core/public/http/http_setup.ts +++ b/src/core/public/http/http_setup.ts @@ -32,8 +32,10 @@ import { format } from 'url'; import { InjectedMetadataSetup } from '../injected_metadata'; import { FatalErrorsSetup } from '../fatal_errors'; import { modifyUrl } from '../utils'; -import { HttpBody, HttpFetchOptions, HttpServiceBase } from './types'; +import { HttpFetchOptions, HttpServiceBase, HttpInterceptor, HttpResponse } from './types'; +import { HttpInterceptController } from './http_intercept_controller'; import { HttpFetchError } from './http_fetch_error'; +import { HttpInterceptHaltError } from './http_intercept_halt_error'; const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/; const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/; @@ -44,9 +46,20 @@ export const setup = ( ): HttpServiceBase => { const loadingCount$ = new BehaviorSubject(0); const stop$ = new Subject(); + const interceptors = new Set(); const kibanaVersion = injectedMetadata.getKibanaVersion(); const basePath = injectedMetadata.getBasePath() || ''; + function intercept(interceptor: HttpInterceptor) { + interceptors.add(interceptor); + + return () => interceptors.delete(interceptor); + } + + function removeAllInterceptors() { + interceptors.clear(); + } + function prependBasePath(path: string): string { return modifyUrl(path, parts => { if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) { @@ -55,7 +68,7 @@ export const setup = ( }); } - async function fetch(path: string, options?: HttpFetchOptions): Promise { + function createRequest(path: string, options?: HttpFetchOptions) { const { query, prependBasePath: shouldPrependBasePath, ...fetchOptions } = merge( { method: 'GET', @@ -82,11 +95,116 @@ export const setup = ( delete fetchOptions.headers['Content-Type']; } + return new Request(url, fetchOptions); + } + + // Request/response interceptors are called in opposite orders. + // Request hooks start from the newest interceptor and end with the oldest. + function interceptRequest( + request: Request, + controller: HttpInterceptController + ): Promise { + let next = request; + + return [...interceptors].reduceRight( + (promise, interceptor) => + promise.then( + async (current: Request) => { + if (controller.halted) { + throw new HttpInterceptHaltError(); + } + + if (!interceptor.request) { + return current; + } + + next = (await interceptor.request(current, controller)) || current; + + return next; + }, + async error => { + if (error instanceof HttpInterceptHaltError) { + throw error; + } else if (controller.halted) { + throw new HttpInterceptHaltError(); + } + + if (!interceptor.requestError) { + throw error; + } + + const nextRequest = await interceptor.requestError( + { error, request: next }, + controller + ); + + if (!nextRequest) { + throw error; + } + + next = nextRequest; + return next; + } + ), + Promise.resolve(request) + ); + } + + // Response hooks start from the oldest interceptor and end with the newest. + async function interceptResponse( + responsePromise: Promise, + controller: HttpInterceptController + ) { + let current: HttpResponse; + + const finalHttpResponse = await [...interceptors].reduce( + (promise, interceptor) => + promise.then( + async httpResponse => { + if (controller.halted) { + throw new HttpInterceptHaltError(); + } + + if (!interceptor.response) { + return httpResponse; + } + + current = (await interceptor.response(httpResponse, controller)) || httpResponse; + + return current; + }, + async error => { + if (error instanceof HttpInterceptHaltError) { + throw error; + } else if (controller.halted) { + throw new HttpInterceptHaltError(); + } + + if (!interceptor.responseError) { + throw error; + } + + const next = await interceptor.responseError({ ...current, error }, controller); + + if (!next) { + throw error; + } + + return next; + } + ), + responsePromise + ); + + return finalHttpResponse.body; + } + + async function fetcher(request: Request): Promise { let response; let body = null; try { - response = await window.fetch(url, fetchOptions as RequestInit); + response = await window.fetch(request); } catch (err) { throw new HttpFetchError(err.message); } @@ -115,7 +233,17 @@ export const setup = ( throw new HttpFetchError(response.statusText, response, body); } - return body; + return { response, body, request }; + } + + function fetch(path: string, options: HttpFetchOptions = {}) { + const controller = new HttpInterceptController(); + const initialRequest = createRequest(path, options); + + return interceptResponse( + interceptRequest(initialRequest, controller).then(fetcher), + controller + ); } function shorthand(method: string) { @@ -189,6 +317,8 @@ export const setup = ( getBasePath, prependBasePath, removeBasePath, + intercept, + removeAllInterceptors, fetch, delete: shorthand('DELETE'), get: shorthand('GET'), diff --git a/src/core/public/http/index.ts b/src/core/public/http/index.ts index 6aac0000b5ee..3edbbc2e362a 100644 --- a/src/core/public/http/index.ts +++ b/src/core/public/http/index.ts @@ -19,4 +19,6 @@ export { HttpService } from './http_service'; export { HttpFetchError } from './http_fetch_error'; -export { HttpServiceBase, HttpSetup, HttpStart } from './types'; +export { HttpInterceptHaltError } from './http_intercept_halt_error'; +export { HttpInterceptController } from './http_intercept_controller'; +export * from './types'; diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 95a94e9351f0..9f28d03e4e5a 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -20,6 +20,8 @@ import { Observable } from 'rxjs'; import { InjectedMetadataSetup } from '../injected_metadata'; import { FatalErrorsSetup } from '../fatal_errors'; +import { HttpInterceptController } from './http_intercept_controller'; +import { HttpFetchError } from './http_fetch_error'; /** @public */ export interface HttpServiceBase { @@ -27,6 +29,8 @@ export interface HttpServiceBase { getBasePath(): string; prependBasePath(path: string): string; removeBasePath(path: string): string; + intercept(interceptor: HttpInterceptor): () => void; + removeAllInterceptors(): void; fetch: HttpHandler; delete: HttpHandler; get: HttpHandler; @@ -80,4 +84,38 @@ export interface HttpFetchOptions extends HttpRequestInit { /** @public */ export type HttpHandler = (path: string, options?: HttpFetchOptions) => Promise; /** @public */ -export type HttpBody = BodyInit | null; +export type HttpBody = BodyInit | null | any; +/** @public */ +export interface HttpResponse { + request: Request; + response?: Response; + body?: HttpBody; +} +/** @public */ +export interface HttpErrorResponse extends HttpResponse { + error: Error | HttpFetchError; +} +/** @public */ +export interface HttpErrorRequest { + request?: Request; + error: Error; +} +/** @public */ +export interface HttpInterceptor { + request?( + request: Request, + controller: HttpInterceptController + ): Promise | Request | void; + requestError?( + httpErrorRequest: HttpErrorRequest, + controller: HttpInterceptController + ): Promise | Request | void; + response?( + httpResponse: HttpResponse, + controller: HttpInterceptController + ): Promise | HttpResponse | void; + responseError?( + httpErrorResponse: HttpErrorResponse, + controller: HttpInterceptController + ): Promise | HttpResponse | void; +} diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 4628bf1f6c10..dd2784dae4da 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -45,7 +45,7 @@ import { ChromeStart, } from './chrome'; import { FatalErrorsSetup, FatalErrorInfo } from './fatal_errors'; -import { HttpServiceBase, HttpSetup, HttpStart } from './http'; +import { HttpServiceBase, HttpSetup, HttpStart, HttpInterceptor } from './http'; import { I18nSetup, I18nStart } from './i18n'; import { InjectedMetadataSetup, InjectedMetadataStart, LegacyNavLink } from './injected_metadata'; import { @@ -129,6 +129,7 @@ export { HttpServiceBase, HttpSetup, HttpStart, + HttpInterceptor, ErrorToastOptions, FatalErrorsSetup, FatalErrorInfo, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index c6ee1c163e77..18739fc8184f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -174,6 +174,26 @@ export interface FatalErrorsSetup { get$: () => Rx.Observable; } +// @public (undocumented) +export interface HttpInterceptor { + // Warning: (ae-forgotten-export) The symbol "HttpInterceptController" needs to be exported by the entry point index.d.ts + // + // (undocumented) + request?(request: Request, controller: HttpInterceptController): Promise | Request | void; + // Warning: (ae-forgotten-export) The symbol "HttpErrorRequest" needs to be exported by the entry point index.d.ts + // + // (undocumented) + requestError?(httpErrorRequest: HttpErrorRequest, controller: HttpInterceptController): Promise | Request | void; + // Warning: (ae-forgotten-export) The symbol "HttpResponse" needs to be exported by the entry point index.d.ts + // + // (undocumented) + response?(httpResponse: HttpResponse, controller: HttpInterceptController): Promise | HttpResponse | void; + // Warning: (ae-forgotten-export) The symbol "HttpErrorResponse" needs to be exported by the entry point index.d.ts + // + // (undocumented) + responseError?(httpErrorResponse: HttpErrorResponse, controller: HttpInterceptController): Promise | HttpResponse | void; +} + // @public (undocumented) export interface HttpServiceBase { // (undocumented) @@ -193,6 +213,8 @@ export interface HttpServiceBase { // (undocumented) head: HttpHandler; // (undocumented) + intercept(interceptor: HttpInterceptor): () => void; + // (undocumented) options: HttpHandler; // (undocumented) patch: HttpHandler; @@ -203,6 +225,8 @@ export interface HttpServiceBase { // (undocumented) put: HttpHandler; // (undocumented) + removeAllInterceptors(): void; + // (undocumented) removeBasePath(path: string): string; // (undocumented) stop(): void; diff --git a/src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap index 8823e7de07d1..cd55c77526d5 100644 --- a/src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap @@ -5,11 +5,9 @@ Array [ Array [ "/foo/bar/api/kibana/settings", Object { - "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", - "credentials": "same-origin", "headers": Object { - "Content-Type": "application/json", "accept": "application/json", + "content-type": "application/json", "kbn-version": "kibanaVersion", }, "method": "POST", @@ -18,11 +16,9 @@ Array [ Array [ "/foo/bar/api/kibana/settings", Object { - "body": "{\\"changes\\":{\\"bar\\":\\"box\\"}}", - "credentials": "same-origin", "headers": Object { - "Content-Type": "application/json", "accept": "application/json", + "content-type": "application/json", "kbn-version": "kibanaVersion", }, "method": "POST", @@ -36,11 +32,9 @@ Array [ Array [ "/foo/bar/api/kibana/settings", Object { - "body": "{\\"changes\\":{\\"foo\\":\\"a\\"}}", - "credentials": "same-origin", "headers": Object { - "Content-Type": "application/json", "accept": "application/json", + "content-type": "application/json", "kbn-version": "kibanaVersion", }, "method": "POST", @@ -49,11 +43,9 @@ Array [ Array [ "/foo/bar/api/kibana/settings", Object { - "body": "{\\"changes\\":{\\"foo\\":\\"d\\"}}", - "credentials": "same-origin", "headers": Object { - "Content-Type": "application/json", "accept": "application/json", + "content-type": "application/json", "kbn-version": "kibanaVersion", }, "method": "POST", @@ -67,11 +59,9 @@ Array [ Array [ "/foo/bar/api/kibana/settings", Object { - "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", - "credentials": "same-origin", "headers": Object { - "Content-Type": "application/json", "accept": "application/json", + "content-type": "application/json", "kbn-version": "kibanaVersion", }, "method": "POST", @@ -80,29 +70,9 @@ Array [ Array [ "/foo/bar/api/kibana/settings", Object { - "body": "{\\"changes\\":{\\"box\\":\\"bar\\"}}", - "credentials": "same-origin", "headers": Object { - "Content-Type": "application/json", - "accept": "application/json", - "kbn-version": "kibanaVersion", - }, - "method": "POST", - }, - ], -] -`; - -exports[`#batchSet buffers changes while first request is in progress, sends buffered changes after first request completes: initial, only one request 1`] = ` -Array [ - Array [ - "/foo/bar/api/kibana/settings", - Object { - "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", - "credentials": "same-origin", - "headers": Object { - "Content-Type": "application/json", "accept": "application/json", + "content-type": "application/json", "kbn-version": "kibanaVersion", }, "method": "POST", @@ -134,16 +104,14 @@ exports[`#batchSet rejects on 404 response 1`] = `"Request failed with status co exports[`#batchSet rejects on 500 1`] = `"Request failed with status code: 500"`; -exports[`#batchSet sends a single change immediately: synchronous fetch 1`] = ` +exports[`#batchSet sends a single change immediately: single change 1`] = ` Array [ Array [ "/foo/bar/api/kibana/settings", Object { - "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", - "credentials": "same-origin", "headers": Object { - "Content-Type": "application/json", "accept": "application/json", + "content-type": "application/json", "kbn-version": "kibanaVersion", }, "method": "POST", diff --git a/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap index 9cf58eb37857..edbdef3f0509 100644 --- a/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap @@ -26,11 +26,13 @@ exports[`#setup constructs UiSettingsClient and UiSettingsApi: UiSettingsApi arg "getBasePath": [MockFunction], "getLoadingCount$": [MockFunction], "head": [MockFunction], + "intercept": [MockFunction], "options": [MockFunction], "patch": [MockFunction], "post": [MockFunction], "prependBasePath": [MockFunction], "put": [MockFunction], + "removeAllInterceptors": [MockFunction], "removeBasePath": [MockFunction], "stop": [MockFunction], }, diff --git a/src/core/public/ui_settings/ui_settings_api.test.ts b/src/core/public/ui_settings/ui_settings_api.test.ts index 8fc4b602d117..048ae2ccbae7 100644 --- a/src/core/public/ui_settings/ui_settings_api.test.ts +++ b/src/core/public/ui_settings/ui_settings_api.test.ts @@ -57,14 +57,14 @@ afterEach(() => { }); describe('#batchSet', () => { - it('sends a single change immediately', () => { + it('sends a single change immediately', async () => { fetchMock.mock('*', { body: { settings: {} }, }); const { uiSettingsApi } = setup(); - uiSettingsApi.batchSet('foo', 'bar'); - expect(fetchMock.calls()).toMatchSnapshot('synchronous fetch'); + await uiSettingsApi.batchSet('foo', 'bar'); + expect(fetchMock.calls()).toMatchSnapshot('single change'); }); it('buffers changes while first request is in progress, sends buffered changes after first request completes', async () => { @@ -77,7 +77,7 @@ describe('#batchSet', () => { uiSettingsApi.batchSet('foo', 'bar'); const finalPromise = uiSettingsApi.batchSet('box', 'bar'); - expect(fetchMock.calls()).toMatchSnapshot('initial, only one request'); + expect(uiSettingsApi.hasPendingChanges()).toBe(true); await finalPromise; expect(fetchMock.calls()).toMatchSnapshot('final, includes both requests'); }); diff --git a/src/core/public/ui_settings/ui_settings_api.ts b/src/core/public/ui_settings/ui_settings_api.ts index 18d73f62519f..33b43107acf1 100644 --- a/src/core/public/ui_settings/ui_settings_api.ts +++ b/src/core/public/ui_settings/ui_settings_api.ts @@ -93,6 +93,13 @@ export class UiSettingsApi { this.loadingCount$.complete(); } + /** + * Report back if there are pending changes waiting to be sent. + */ + public hasPendingChanges() { + return !!(this.pendingChanges && this.sendInProgress); + } + /** * If there are changes that need to be sent to the server and there is not already a * request in progress, this method will start a request sending those changes. Once diff --git a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js index 9205e828d18d..8264f0ca5538 100644 --- a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js +++ b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js @@ -35,7 +35,7 @@ import 'custom-event-polyfill'; import 'whatwg-fetch'; import 'abortcontroller-polyfill'; import 'childnode-remove-polyfill'; -import sinon from 'sinon'; +import fetchMock from 'fetch-mock/es5/client'; import { CoreSystem } from '__kibanaCore__'; @@ -59,22 +59,12 @@ const uiCapabilities = { }, }; -// Stub fetch for CoreSystem calls. -const fetchStub = sinon.stub(window, 'fetch'); -fetchStub.callsFake((url, options) => { - if (url !== '/api/capabilities') { - console.warn('Stubbed window.fetch does not support this request.'); - return Promise.resolve(new window.Response('Resource not found', { status: 404 })); - } - - return Promise.resolve( - new window.Response( - JSON.stringify({ capabilities: uiCapabilities })), - { - status: 200, - headers: { 'Content-Type': 'application/json' } - } - ); +// Mock fetch for CoreSystem calls. +fetchMock.config.fallbackToNetwork = true; +fetchMock.post(/\\/api\\/capabilities/, { + status: 200, + body: JSON.stringify({ capabilities: uiCapabilities }), + headers: { 'Content-Type': 'application/json' }, }); // render the core system in a child of the body as the default children of the body diff --git a/src/legacy/ui/public/kfetch/kfetch.test.ts b/src/legacy/ui/public/kfetch/kfetch.test.ts index 7ac51d2787cd..93dca785f578 100644 --- a/src/legacy/ui/public/kfetch/kfetch.test.ts +++ b/src/legacy/ui/public/kfetch/kfetch.test.ts @@ -46,7 +46,7 @@ describe('kfetch', () => { fetchMock.get('*', {}); await kfetch({ pathname: '/my/path', headers: { 'Content-Type': 'CustomContentType' } }); expect(fetchMock.lastOptions()!.headers).toMatchObject({ - 'Content-Type': 'CustomContentType', + 'content-type': 'CustomContentType', }); }); @@ -64,9 +64,9 @@ describe('kfetch', () => { }); expect(fetchMock.lastOptions()!.headers).toEqual({ - 'Content-Type': 'application/json', + 'content-type': 'application/json', 'kbn-version': 'kibanaVersion', - myHeader: 'foo', + myheader: 'foo', }); }); @@ -92,11 +92,11 @@ describe('kfetch', () => { fetchMock.get('*', {}); await kfetch({ pathname: '/my/path' }); + expect(fetchMock.lastCall()!.request.credentials).toBe('same-origin'); expect(fetchMock.lastOptions()!).toMatchObject({ method: 'GET', - credentials: 'same-origin', headers: { - 'Content-Type': 'application/json', + 'content-type': 'application/json', 'kbn-version': 'kibanaVersion', }, }); @@ -359,7 +359,7 @@ describe('kfetch', () => { addInterceptor({ request: config => ({ ...config, - addedByRequestInterceptor: true, + pathname: '/my/intercepted-route', }), response: res => ({ ...res, @@ -371,8 +371,8 @@ describe('kfetch', () => { }); it('should modify request', () => { + expect(fetchMock.lastUrl()).toContain('/my/intercepted-route'); expect(fetchMock.lastOptions()!).toMatchObject({ - addedByRequestInterceptor: true, method: 'GET', }); }); @@ -393,7 +393,7 @@ describe('kfetch', () => { request: config => Promise.resolve({ ...config, - addedByRequestInterceptor: true, + pathname: '/my/intercepted-route', }), response: res => Promise.resolve({ @@ -406,8 +406,8 @@ describe('kfetch', () => { }); it('should modify request', () => { + expect(fetchMock.lastUrl()).toContain('/my/intercepted-route'); expect(fetchMock.lastOptions()!).toMatchObject({ - addedByRequestInterceptor: true, method: 'GET', }); }); diff --git a/x-pack/plugins/kuery_autocomplete/public/autocomplete_providers/__tests__/value.js b/x-pack/plugins/kuery_autocomplete/public/autocomplete_providers/__tests__/value.js index d2cce4fe5928..8524b1c890f5 100644 --- a/x-pack/plugins/kuery_autocomplete/public/autocomplete_providers/__tests__/value.js +++ b/x-pack/plugins/kuery_autocomplete/public/autocomplete_providers/__tests__/value.js @@ -72,13 +72,13 @@ describe('Kuery value suggestions', function () { const suggestions = await getSuggestions({ fieldName, prefix, suffix }); const lastCall = fetchMock.lastCall(fetchUrlMatcher, 'POST'); - expect(lastCall[0]).to.eql('/api/kibana/suggestions/values/logstash-*'); + + expect(lastCall.request._bodyInit, '{"query":"","field":"machine.os.raw","boolFilter":[]}'); + expect(lastCall[0]).to.match(/\/api\/kibana\/suggestions\/values\/logstash-\*/); expect(lastCall[1]).to.eql({ method: 'POST', - body: '{"query":"","field":"machine.os.raw","boolFilter":[]}', - credentials: 'same-origin', headers: { - 'Content-Type': 'application/json', + 'content-type': 'application/json', 'kbn-version': '1.2.3', }, });