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:
parent
b17c6b849c
commit
1b096f3b73
|
@ -0,0 +1,22 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index) > [kibana-plugin-public](./kibana-plugin-public.md) > [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) | |
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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
|
||||
|
||||
<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`
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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
|
||||
|
||||
<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`
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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
|
||||
|
||||
<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`
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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
|
||||
|
||||
<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`
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
intercept(interceptor: HttpInterceptor): () => void;
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| interceptor | <code>HttpInterceptor</code> | |
|
||||
|
||||
<b>Returns:</b>
|
||||
|
||||
`() => void`
|
||||
|
|
@ -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) | |
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
removeAllInterceptors(): void;
|
||||
```
|
||||
<b>Returns:</b>
|
||||
|
||||
`void`
|
||||
|
|
@ -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) | |
|
||||
|
|
30
src/core/public/http/http_intercept_controller.ts
Normal file
30
src/core/public/http/http_intercept_controller.ts
Normal 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;
|
||||
}
|
||||
}
|
30
src/core/public/http/http_intercept_halt_error.ts
Normal file
30
src/core/public/http/http_intercept_halt_error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue