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
This commit is contained in:
Eli Perelman 2019-05-31 12:56:47 -05:00 committed by GitHub
parent b17c6b849c
commit 1b096f3b73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 719 additions and 87 deletions

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md)
## HttpInterceptor interface
<b>Signature:</b>
```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) | |

View file

@ -0,0 +1,23 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) &gt; [request](./kibana-plugin-public.httpinterceptor.request.md)
## HttpInterceptor.request() method
<b>Signature:</b>
```typescript
request?(request: Request, controller: HttpInterceptController): Promise<Request> | Request | void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| request | <code>Request</code> | |
| controller | <code>HttpInterceptController</code> | |
<b>Returns:</b>
`Promise<Request> | Request | void`

View file

@ -0,0 +1,23 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) &gt; [requestError](./kibana-plugin-public.httpinterceptor.requesterror.md)
## HttpInterceptor.requestError() method
<b>Signature:</b>
```typescript
requestError?(httpErrorRequest: HttpErrorRequest, controller: HttpInterceptController): Promise<Request> | Request | void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| httpErrorRequest | <code>HttpErrorRequest</code> | |
| controller | <code>HttpInterceptController</code> | |
<b>Returns:</b>
`Promise<Request> | Request | void`

View file

@ -0,0 +1,23 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) &gt; [response](./kibana-plugin-public.httpinterceptor.response.md)
## HttpInterceptor.response() method
<b>Signature:</b>
```typescript
response?(httpResponse: HttpResponse, controller: HttpInterceptController): Promise<HttpResponse> | HttpResponse | void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| httpResponse | <code>HttpResponse</code> | |
| controller | <code>HttpInterceptController</code> | |
<b>Returns:</b>
`Promise<HttpResponse> | HttpResponse | void`

View file

@ -0,0 +1,23 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) &gt; [responseError](./kibana-plugin-public.httpinterceptor.responseerror.md)
## HttpInterceptor.responseError() method
<b>Signature:</b>
```typescript
responseError?(httpErrorResponse: HttpErrorResponse, controller: HttpInterceptController): Promise<HttpResponse> | HttpResponse | void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| httpErrorResponse | <code>HttpErrorResponse</code> | |
| controller | <code>HttpInterceptController</code> | |
<b>Returns:</b>
`Promise<HttpResponse> | HttpResponse | void`

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) &gt; [intercept](./kibana-plugin-public.httpservicebase.intercept.md)
## HttpServiceBase.intercept() method
<b>Signature:</b>
```typescript
intercept(interceptor: HttpInterceptor): () => void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| interceptor | <code>HttpInterceptor</code> | |
<b>Returns:</b>
`() => void`

View file

@ -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) | |

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) &gt; [removeAllInterceptors](./kibana-plugin-public.httpservicebase.removeallinterceptors.md)
## HttpServiceBase.removeAllInterceptors() method
<b>Signature:</b>
```typescript
removeAllInterceptors(): void;
```
<b>Returns:</b>
`void`

View file

@ -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 <code>message</code> and <code>stack</code> 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) | |

View file

@ -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;
}
}

View file

@ -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);
}
}
}

View file

@ -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<HttpSetup> => createServiceMock();

View file

@ -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();

View file

@ -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<HttpInterceptor>();
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<HttpBody> {
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<Request> {
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<HttpResponse>,
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<HttpResponse> {
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'),

View file

@ -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';

View file

@ -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<HttpBody>;
/** @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> | Request | void;
requestError?(
httpErrorRequest: HttpErrorRequest,
controller: HttpInterceptController
): Promise<Request> | Request | void;
response?(
httpResponse: HttpResponse,
controller: HttpInterceptController
): Promise<HttpResponse> | HttpResponse | void;
responseError?(
httpErrorResponse: HttpErrorResponse,
controller: HttpInterceptController
): Promise<HttpResponse> | HttpResponse | void;
}

View file

@ -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,

View file

@ -174,6 +174,26 @@ export interface FatalErrorsSetup {
get$: () => Rx.Observable<FatalErrorInfo>;
}
// @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> | 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> | 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> | 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> | 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;

View file

@ -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",

View file

@ -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],
},

View file

@ -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');
});

View file

@ -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

View file

@ -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

View file

@ -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',
});
});

View file

@ -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',
},
});