Expose an HTTP-request browser client (#35486) (#36397)

* Expose an HTTP-request browser client

* Fix failing tests from kfetch refactor

* Make abort() non-enumerable, fix review issues

* Move kfetch test setup to build-excluded location

* Add ndjson tests to browser http service

* Lint fixes

* Fix missing update of del to delete in http mock

* Fix problems with merging headers with undefined Content-Type

* Delete correct property from updated options

* Linting fix

* Fix reference to kfetch_test_setup due to moving test file

* Add tests and fix implementation of abortables

* Add missing http start mock contract, fix test in CI

* Remove abortable promise functionality

* Fix DELETE method handler, remove unnecessary promise wrapper
This commit is contained in:
Eli Perelman 2019-05-10 13:48:35 -05:00 committed by GitHub
parent 9d7324f95e
commit c3f749f896
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 626 additions and 235 deletions

View file

@ -116,9 +116,12 @@ export class CoreSystem {
const i18n = this.i18n.setup();
const injectedMetadata = this.injectedMetadata.setup();
this.fatalErrorsSetup = this.fatalErrors.setup({ injectedMetadata, i18n });
const http = this.http.setup({ fatalErrors: this.fatalErrorsSetup });
const basePath = this.basePath.setup({ injectedMetadata });
const http = this.http.setup({
basePath,
injectedMetadata,
fatalErrors: this.fatalErrorsSetup,
});
const uiSettings = this.uiSettings.setup({
http,
injectedMetadata,

View file

@ -0,0 +1 @@
{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"title":"Log Agents","uiStateJSON":"{}","visState":"{\"title\":\"Log Agents\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{\"text\":\"agent.raw: Descending\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"agent.raw\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}"},"id":"082f1d60-a2e7-11e7-bb30-233be9be6a15","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"f1e4c910-a2e6-11e7-bb30-233be9be6a15","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","version":1}

View file

@ -0,0 +1,91 @@
/*
* 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.
*/
import { merge } from 'lodash';
import { format } from 'url';
import { HttpFetchOptions, HttpBody, Deps } from './types';
import { HttpFetchError } from './http_fetch_error';
const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/;
const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/;
export const setup = ({ basePath, injectedMetadata }: Deps) => {
async function fetch(path: string, options: HttpFetchOptions = {}): Promise<HttpBody> {
const { query, prependBasePath, ...fetchOptions } = merge(
{
method: 'GET',
credentials: 'same-origin',
prependBasePath: true,
headers: {
'kbn-version': injectedMetadata.getKibanaVersion(),
'Content-Type': 'application/json',
},
},
options
);
const url = format({
pathname: prependBasePath ? basePath.addToPath(path) : path,
query,
});
if (
options.headers &&
'Content-Type' in options.headers &&
options.headers['Content-Type'] === undefined
) {
delete fetchOptions.headers['Content-Type'];
}
let response;
let body = null;
try {
response = await window.fetch(url, fetchOptions as RequestInit);
} catch (err) {
throw new HttpFetchError(err.message);
}
const contentType = response.headers.get('Content-Type') || '';
try {
if (NDJSON_CONTENT.test(contentType)) {
body = await response.blob();
} else if (JSON_CONTENT.test(contentType)) {
body = await response.json();
} else {
body = await response.text();
}
} catch (err) {
throw new HttpFetchError(err.message, response, body);
}
if (!response.ok) {
throw new HttpFetchError(response.statusText, response, body);
}
return body;
}
function shorthand(method: string) {
return (path: string, options: HttpFetchOptions = {}) => fetch(path, { ...options, method });
}
return { fetch, shorthand };
};

View file

@ -17,29 +17,14 @@
* under the License.
*/
import { kfetch, KFetchKibanaOptions, KFetchOptions } from './kfetch';
export class HttpFetchError extends Error {
constructor(message: string, public readonly response?: Response, public readonly body?: any) {
super(message);
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
function createAbortable() {
const abortController = new AbortController();
const { signal, abort } = abortController;
return {
signal,
abort: abort.bind(abortController),
};
}
export function kfetchAbortable(
fetchOptions?: Omit<KFetchOptions, 'signal'>,
kibanaOptions?: KFetchKibanaOptions
) {
const { signal, abort } = createAbortable();
const fetching = kfetch({ ...fetchOptions, signal }, kibanaOptions);
return {
fetching,
abort,
};
// 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, HttpFetchError);
}
}
}

View file

@ -16,25 +16,27 @@
* specific language governing permissions and limitations
* under the License.
*/
import { HttpService, HttpSetup } from './http_service';
const createSetupContractMock = () => {
const setupContract: jest.Mocked<HttpSetup> = {
addLoadingCount: jest.fn(),
getLoadingCount$: jest.fn(),
};
return setupContract;
};
import { HttpService, HttpSetup, HttpStart } from './http_service';
type HttpServiceContract = PublicMethodsOf<HttpService>;
const createMock = () => {
const mocked: jest.Mocked<HttpServiceContract> = {
setup: jest.fn(),
stop: jest.fn(),
};
mocked.setup.mockReturnValue(createSetupContractMock());
return mocked;
};
const createSetupContractMock = (): jest.Mocked<HttpSetup> => ({
fetch: jest.fn(),
get: jest.fn(),
head: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
options: jest.fn(),
addLoadingCount: jest.fn(),
getLoadingCount$: jest.fn(),
});
const createStartContractMock = (): jest.Mocked<HttpStart> => undefined;
const createMock = (): jest.Mocked<PublicMethodsOf<HttpService>> => ({
setup: jest.fn().mockReturnValue(createSetupContractMock()),
start: jest.fn().mockReturnValue(createStartContractMock()),
stop: jest.fn(),
});
export const httpServiceMock = {
create: createMock,

View file

@ -19,35 +19,254 @@
import * as Rx from 'rxjs';
import { toArray } from 'rxjs/operators';
// @ts-ignore
import fetchMock from 'fetch-mock/es5/client';
import { BasePathService } from '../base_path/base_path_service';
import { fatalErrorsServiceMock } from '../fatal_errors/fatal_errors_service.mock';
import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock';
import { HttpService } from './http_service';
import { readFileSync } from 'fs';
import { join } from 'path';
function setupService() {
const service = new HttpService();
const httpService = new HttpService();
const fatalErrors = fatalErrorsServiceMock.createSetupContract();
const setup = service.setup({ fatalErrors });
const injectedMetadata = injectedMetadataServiceMock.createSetupContract();
return { service, fatalErrors, setup };
injectedMetadata.getBasePath.mockReturnValueOnce('http://localhost/myBase');
const basePath = new BasePathService().setup({ injectedMetadata });
const http = httpService.setup({ basePath, fatalErrors, injectedMetadata });
return { httpService, fatalErrors, http };
}
describe('http requests', async () => {
afterEach(() => {
fetchMock.restore();
});
it('should use supplied request method', async () => {
const { http } = setupService();
fetchMock.post('*', {});
await http.fetch('/my/path', { method: 'POST' });
expect(fetchMock.lastOptions()!.method).toBe('POST');
});
it('should use supplied Content-Type', async () => {
const { http } = setupService();
fetchMock.get('*', {});
await http.fetch('/my/path', { headers: { 'Content-Type': 'CustomContentType' } });
expect(fetchMock.lastOptions()!.headers).toMatchObject({
'Content-Type': 'CustomContentType',
});
});
it('should use supplied pathname and querystring', async () => {
const { http } = setupService();
fetchMock.get('*', {});
await http.fetch('/my/path', { query: { a: 'b' } });
expect(fetchMock.lastUrl()).toBe('http://localhost/myBase/my/path?a=b');
});
it('should use supplied headers', async () => {
const { http } = setupService();
fetchMock.get('*', {});
await http.fetch('/my/path', {
headers: { myHeader: 'foo' },
});
expect(fetchMock.lastOptions()!.headers).toEqual({
'Content-Type': 'application/json',
'kbn-version': 'kibanaVersion',
myHeader: 'foo',
});
});
it('should return response', async () => {
const { http } = setupService();
fetchMock.get('*', { foo: 'bar' });
const json = await http.fetch('/my/path');
expect(json).toEqual({ foo: 'bar' });
});
it('should prepend url with basepath by default', async () => {
const { http } = setupService();
fetchMock.get('*', {});
await http.fetch('/my/path');
expect(fetchMock.lastUrl()).toBe('http://localhost/myBase/my/path');
});
it('should not prepend url with basepath when disabled', async () => {
const { http } = setupService();
fetchMock.get('*', {});
await http.fetch('my/path', { prependBasePath: false });
expect(fetchMock.lastUrl()).toBe('/my/path');
});
it('should make request with defaults', async () => {
const { http } = setupService();
fetchMock.get('*', {});
await http.fetch('/my/path');
expect(fetchMock.lastOptions()!).toMatchObject({
method: 'GET',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'kbn-version': 'kibanaVersion',
},
});
});
it('should reject on network error', async () => {
const { http } = setupService();
expect.assertions(1);
fetchMock.get('*', { status: 500 });
await expect(http.fetch('/my/path')).rejects.toThrow(/Internal Server Error/);
});
it('should contain error message when throwing response', async () => {
const { http } = setupService();
fetchMock.get('*', { status: 404, body: { foo: 'bar' } });
await expect(http.fetch('/my/path')).rejects.toMatchObject({
message: 'Not Found',
body: {
foo: 'bar',
},
response: {
status: 404,
url: 'http://localhost/myBase/my/path',
},
});
});
it('should support get() helper', async () => {
const { http } = setupService();
fetchMock.get('*', {});
await http.get('/my/path', { method: 'POST' });
expect(fetchMock.lastOptions()!.method).toBe('GET');
});
it('should support head() helper', async () => {
const { http } = setupService();
fetchMock.head('*', {});
await http.head('/my/path', { method: 'GET' });
expect(fetchMock.lastOptions()!.method).toBe('HEAD');
});
it('should support post() helper', async () => {
const { http } = setupService();
fetchMock.post('*', {});
await http.post('/my/path', { method: 'GET', body: '{}' });
expect(fetchMock.lastOptions()!.method).toBe('POST');
});
it('should support put() helper', async () => {
const { http } = setupService();
fetchMock.put('*', {});
await http.put('/my/path', { method: 'GET', body: '{}' });
expect(fetchMock.lastOptions()!.method).toBe('PUT');
});
it('should support patch() helper', async () => {
const { http } = setupService();
fetchMock.patch('*', {});
await http.patch('/my/path', { method: 'GET', body: '{}' });
expect(fetchMock.lastOptions()!.method).toBe('PATCH');
});
it('should support delete() helper', async () => {
const { http } = setupService();
fetchMock.delete('*', {});
await http.delete('/my/path', { method: 'GET' });
expect(fetchMock.lastOptions()!.method).toBe('DELETE');
});
it('should support options() helper', async () => {
const { http } = setupService();
fetchMock.mock('*', { method: 'OPTIONS' });
await http.options('/my/path', { method: 'GET' });
expect(fetchMock.lastOptions()!.method).toBe('OPTIONS');
});
it('should make requests for NDJSON content', async () => {
const { http } = setupService();
const content = readFileSync(join(__dirname, '_import_objects.ndjson'), { encoding: 'utf-8' });
const body = new FormData();
body.append('file', content);
fetchMock.post('*', {
body: content,
headers: { 'Content-Type': 'application/ndjson' },
});
const data = await http.post('/my/path', {
body,
headers: {
'Content-Type': undefined,
},
});
expect(data).toBeInstanceOf(Blob);
const ndjson = await new Response(data).text();
expect(ndjson).toEqual(content);
});
});
describe('addLoadingCount()', async () => {
it('subscribes to passed in sources, unsubscribes on stop', () => {
const { service, setup } = setupService();
const { httpService, http } = setupService();
const unsubA = jest.fn();
const subA = jest.fn().mockReturnValue(unsubA);
setup.addLoadingCount(new Rx.Observable(subA));
http.addLoadingCount(new Rx.Observable(subA));
expect(subA).toHaveBeenCalledTimes(1);
expect(unsubA).not.toHaveBeenCalled();
const unsubB = jest.fn();
const subB = jest.fn().mockReturnValue(unsubB);
setup.addLoadingCount(new Rx.Observable(subB));
http.addLoadingCount(new Rx.Observable(subB));
expect(subB).toHaveBeenCalledTimes(1);
expect(unsubB).not.toHaveBeenCalled();
service.stop();
httpService.stop();
expect(subA).toHaveBeenCalledTimes(1);
expect(unsubA).toHaveBeenCalledTimes(1);
@ -56,35 +275,35 @@ describe('addLoadingCount()', async () => {
});
it('adds a fatal error if source observables emit an error', async () => {
const { setup, fatalErrors } = setupService();
const { http, fatalErrors } = setupService();
setup.addLoadingCount(Rx.throwError(new Error('foo bar')));
http.addLoadingCount(Rx.throwError(new Error('foo bar')));
expect(fatalErrors.add.mock.calls).toMatchSnapshot();
});
it('adds a fatal error if source observable emits a negative number', async () => {
const { setup, fatalErrors } = setupService();
const { http, fatalErrors } = setupService();
setup.addLoadingCount(Rx.of(1, 2, 3, 4, -9));
http.addLoadingCount(Rx.of(1, 2, 3, 4, -9));
expect(fatalErrors.add.mock.calls).toMatchSnapshot();
});
});
describe('getLoadingCount$()', async () => {
it('emits 0 initially, the right count when sources emit their own count, and ends with zero', async () => {
const { service, setup } = setupService();
const { httpService, http } = setupService();
const countA$ = new Rx.Subject<number>();
const countB$ = new Rx.Subject<number>();
const countC$ = new Rx.Subject<number>();
const promise = setup
const promise = http
.getLoadingCount$()
.pipe(toArray())
.toPromise();
setup.addLoadingCount(countA$);
setup.addLoadingCount(countB$);
setup.addLoadingCount(countC$);
http.addLoadingCount(countA$);
http.addLoadingCount(countB$);
http.addLoadingCount(countC$);
countA$.next(100);
countB$.next(10);
@ -94,20 +313,20 @@ describe('getLoadingCount$()', async () => {
countC$.complete();
countB$.next(0);
service.stop();
httpService.stop();
expect(await promise).toMatchSnapshot();
});
it('only emits when loading count changes', async () => {
const { service, setup } = setupService();
const { httpService, http } = setupService();
const count$ = new Rx.Subject<number>();
const promise = setup
const promise = http
.getLoadingCount$()
.pipe(toArray())
.toPromise();
setup.addLoadingCount(count$);
http.addLoadingCount(count$);
count$.next(0);
count$.next(0);
count$.next(0);
@ -115,7 +334,7 @@ describe('getLoadingCount$()', async () => {
count$.next(0);
count$.next(1);
count$.next(1);
service.stop();
httpService.stop();
expect(await promise).toMatchSnapshot();
});

View file

@ -28,19 +28,26 @@ import {
tap,
} from 'rxjs/operators';
import { FatalErrorsSetup } from '../fatal_errors';
interface Deps {
fatalErrors: FatalErrorsSetup;
}
import { Deps } from './types';
import { setup } from './fetch';
/** @internal */
export class HttpService {
private readonly loadingCount$ = new Rx.BehaviorSubject(0);
private readonly stop$ = new Rx.Subject();
public setup({ fatalErrors }: Deps) {
public setup(deps: Deps) {
const { fetch, shorthand } = setup(deps);
return {
fetch,
delete: shorthand('DELETE'),
get: shorthand('GET'),
head: shorthand('HEAD'),
options: shorthand('OPTIONS'),
patch: shorthand('PATCH'),
post: shorthand('POST'),
put: shorthand('PUT'),
addLoadingCount: (count$: Rx.Observable<number>) => {
count$
.pipe(
@ -67,7 +74,7 @@ export class HttpService {
this.loadingCount$.next(this.loadingCount$.getValue() + delta);
},
error: error => {
fatalErrors.add(error);
deps.fatalErrors.add(error);
},
});
},
@ -78,6 +85,9 @@ export class HttpService {
};
}
// eslint-disable-next-line no-unused-params
public start(deps: Deps) {}
public stop() {
this.stop$.next();
this.loadingCount$.complete();
@ -86,3 +96,4 @@ export class HttpService {
/** @public */
export type HttpSetup = ReturnType<HttpService['setup']>;
export type HttpStart = ReturnType<HttpService['start']>;

View file

@ -18,3 +18,4 @@
*/
export { HttpService, HttpSetup } from './http_service';
export { HttpFetchError } from './http_fetch_error';

View file

@ -0,0 +1,55 @@
/*
* 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.
*/
import { BasePathSetup } from '../base_path';
import { InjectedMetadataSetup } from '../injected_metadata';
import { FatalErrorsSetup } from '../fatal_errors';
export interface HttpHeadersInit {
[name: string]: any;
}
export interface HttpRequestInit {
body?: BodyInit | null;
cache?: RequestCache;
credentials?: RequestCredentials;
headers?: HttpHeadersInit;
integrity?: string;
keepalive?: boolean;
method?: string;
mode?: RequestMode;
redirect?: RequestRedirect;
referrer?: string;
referrerPolicy?: ReferrerPolicy;
signal?: AbortSignal | null;
window?: any;
}
export interface Deps {
basePath: BasePathSetup;
injectedMetadata: InjectedMetadataSetup;
fatalErrors: FatalErrorsSetup;
}
export interface HttpFetchQuery {
[key: string]: string | number | boolean | undefined;
}
export interface HttpFetchOptions extends HttpRequestInit {
query?: HttpFetchQuery;
prependBasePath?: boolean;
headers?: HttpHeadersInit;
}
export type HttpBody = BodyInit | null;

View file

@ -70,6 +70,7 @@ export class LegacyPlatformService {
require('ui/metadata').__newPlatformSetup__(injectedMetadata.getLegacyMetadata());
require('ui/i18n').__newPlatformSetup__(i18n.Context);
require('ui/notify/fatal_error').__newPlatformSetup__(fatalErrors);
require('ui/kfetch').__newPlatformSetup__(http);
require('ui/notify/toasts').__newPlatformSetup__(notifications.toasts);
require('ui/chrome/api/loading_count').__newPlatformSetup__(http);
require('ui/chrome/api/base_path').__newPlatformSetup__(basePath);

View file

@ -17,6 +17,9 @@
* under the License.
*/
import { createKfetch } from 'ui/kfetch/kfetch';
import { setup } from '../../../../../../test_utils/public/kfetch_test_setup';
const mockChromeFactory = jest.fn(() => {
return {
getBasePath: () => `foo`,
@ -47,6 +50,7 @@ export const mockPersistedLogFactory = jest.fn<jest.Mocked<typeof mockPersistedL
export const mockGetAutocompleteSuggestions = jest.fn(() => Promise.resolve([]));
const mockAutocompleteProvider = jest.fn(() => mockGetAutocompleteSuggestions);
export const mockGetAutocompleteProvider = jest.fn(() => mockAutocompleteProvider);
const mockKfetch = jest.fn(() => createKfetch(setup().http));
jest.mock('ui/chrome', () => mockChromeFactory());
jest.mock('ui/kfetch', () => ({
@ -63,6 +67,10 @@ jest.mock('ui/metadata', () => ({
jest.mock('ui/autocomplete_providers', () => ({
getAutocompleteProvider: mockGetAutocompleteProvider,
}));
jest.mock('ui/kfetch', () => ({
__newPlatformSetup__: jest.fn(),
kfetch: mockKfetch,
}));
import _ from 'lodash';
// Using doMock to avoid hoisting so that I can override only the debounce method in lodash

View file

@ -17,20 +17,18 @@
* under the License.
*/
jest.mock('../chrome', () => ({
addBasePath: path => `myBase/${path}`,
}));
jest.mock('../metadata', () => ({
metadata: {
version: 'my-version',
},
}));
// @ts-ignore
import fetchMock from 'fetch-mock/es5/client';
import { kfetch } from 'ui/kfetch';
import { __newPlatformSetup__, kfetch } from '../kfetch';
import { setup } from '../../../../test_utils/public/kfetch_test_setup';
import { isAutoCreateIndexError } from './error_auto_create_index';
describe('isAutoCreateIndexError correctly handles KFetchError thrown by kfetch', () => {
beforeAll(() => {
__newPlatformSetup__(setup().http);
});
describe('404', () => {
beforeEach(() => {
fetchMock.post({
@ -45,7 +43,7 @@ describe('isAutoCreateIndexError correctly handles KFetchError thrown by kfetch'
test('should return false', async () => {
expect.assertions(1);
try {
await kfetch({ method: 'POST', pathname: 'my/path' });
await kfetch({ method: 'POST', pathname: '/my/path' });
} catch (kfetchError) {
expect(isAutoCreateIndexError(kfetchError)).toBe(false);
}
@ -66,7 +64,7 @@ describe('isAutoCreateIndexError correctly handles KFetchError thrown by kfetch'
test('should return false', async () => {
expect.assertions(1);
try {
await kfetch({ method: 'POST', pathname: 'my/path' });
await kfetch({ method: 'POST', pathname: '/my/path' });
} catch (kfetchError) {
expect(isAutoCreateIndexError(kfetchError)).toBe(false);
}
@ -90,7 +88,7 @@ describe('isAutoCreateIndexError correctly handles KFetchError thrown by kfetch'
test('should return true', async () => {
expect.assertions(1);
try {
await kfetch({ method: 'POST', pathname: 'my/path' });
await kfetch({ method: 'POST', pathname: '/my/path' });
} catch (kfetchError) {
expect(isAutoCreateIndexError(kfetchError)).toBe(true);
}

View file

@ -0,0 +1 @@
{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"title":"Log Agents","uiStateJSON":"{}","visState":"{\"title\":\"Log Agents\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{\"text\":\"agent.raw: Descending\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"agent.raw\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}"},"id":"082f1d60-a2e7-11e7-bb30-233be9be6a15","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"f1e4c910-a2e6-11e7-bb30-233be9be6a15","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","version":1}

View file

@ -17,5 +17,23 @@
* under the License.
*/
export { kfetch, addInterceptor, KFetchOptions, KFetchQuery } from './kfetch';
export { kfetchAbortable } from './kfetch_abortable';
import { createKfetch, KFetchKibanaOptions, KFetchOptions } from './kfetch';
export { addInterceptor, KFetchOptions, KFetchQuery } from './kfetch';
import { HttpSetup } from '../../../../core/public';
let http: HttpSetup;
let kfetchInstance: (options: KFetchOptions, kfetchOptions?: KFetchKibanaOptions) => any;
export function __newPlatformSetup__(httpSetup: HttpSetup) {
if (http) {
throw new Error('ui/kfetch already initialized with New Platform APIs');
}
http = httpSetup;
kfetchInstance = createKfetch(http);
}
export const kfetch = (options: KFetchOptions, kfetchOptions?: KFetchKibanaOptions) => {
return kfetchInstance(options, kfetchOptions);
};

View file

@ -17,28 +17,20 @@
* under the License.
*/
jest.mock('../chrome', () => ({
addBasePath: (path: string) => `http://localhost/myBase/${path}`,
}));
jest.mock('../metadata', () => ({
metadata: {
version: 'my-version',
},
}));
// @ts-ignore
import fetchMock from 'fetch-mock/es5/client';
import {
addInterceptor,
Interceptor,
kfetch,
resetInterceptors,
withDefaultOptions,
} from './kfetch';
import { readFileSync } from 'fs';
import { join } from 'path';
import { __newPlatformSetup__, addInterceptor, kfetch, KFetchOptions } from '.';
import { Interceptor, resetInterceptors, withDefaultOptions } from './kfetch';
import { KFetchError } from './kfetch_error';
import { setup } from '../../../../test_utils/public/kfetch_test_setup';
describe('kfetch', () => {
beforeAll(() => {
__newPlatformSetup__(setup().http);
});
afterEach(() => {
fetchMock.restore();
resetInterceptors();
@ -46,13 +38,13 @@ describe('kfetch', () => {
it('should use supplied request method', async () => {
fetchMock.post('*', {});
await kfetch({ pathname: 'my/path', method: 'POST' });
await kfetch({ pathname: '/my/path', method: 'POST' });
expect(fetchMock.lastOptions()!.method).toBe('POST');
});
it('should use supplied Content-Type', async () => {
fetchMock.get('*', {});
await kfetch({ pathname: 'my/path', headers: { 'Content-Type': 'CustomContentType' } });
await kfetch({ pathname: '/my/path', headers: { 'Content-Type': 'CustomContentType' } });
expect(fetchMock.lastOptions()!.headers).toMatchObject({
'Content-Type': 'CustomContentType',
});
@ -60,64 +52,88 @@ describe('kfetch', () => {
it('should use supplied pathname and querystring', async () => {
fetchMock.get('*', {});
await kfetch({ pathname: 'my/path', query: { a: 'b' } });
await kfetch({ pathname: '/my/path', query: { a: 'b' } });
expect(fetchMock.lastUrl()).toBe('http://localhost/myBase/my/path?a=b');
});
it('should use supplied headers', async () => {
fetchMock.get('*', {});
await kfetch({
pathname: 'my/path',
pathname: '/my/path',
headers: { myHeader: 'foo' },
});
expect(fetchMock.lastOptions()!.headers).toEqual({
'Content-Type': 'application/json',
'kbn-version': 'my-version',
'kbn-version': 'kibanaVersion',
myHeader: 'foo',
});
});
it('should return response', async () => {
fetchMock.get('*', { foo: 'bar' });
const res = await kfetch({ pathname: 'my/path' });
const res = await kfetch({ pathname: '/my/path' });
expect(res).toEqual({ foo: 'bar' });
});
it('should prepend url with basepath by default', async () => {
fetchMock.get('*', {});
await kfetch({ pathname: 'my/path' });
await kfetch({ pathname: '/my/path' });
expect(fetchMock.lastUrl()).toBe('http://localhost/myBase/my/path');
});
it('should not prepend url with basepath when disabled', async () => {
fetchMock.get('*', {});
await kfetch({ pathname: 'my/path' }, { prependBasePath: false });
await kfetch({ pathname: '/my/path' }, { prependBasePath: false });
expect(fetchMock.lastUrl()).toBe('/my/path');
});
it('should make request with defaults', async () => {
fetchMock.get('*', {});
await kfetch({ pathname: 'my/path' });
await kfetch({ pathname: '/my/path' });
expect(fetchMock.lastOptions()!).toEqual({
expect(fetchMock.lastOptions()!).toMatchObject({
method: 'GET',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'kbn-version': 'my-version',
'kbn-version': 'kibanaVersion',
},
});
});
it('should make requests for NDJSON content', async () => {
const content = readFileSync(join(__dirname, '_import_objects.ndjson'), { encoding: 'utf-8' });
fetchMock.post('*', {
body: content,
headers: { 'Content-Type': 'application/ndjson' },
});
const data = await kfetch({
method: 'POST',
pathname: '/my/path',
body: content,
headers: {
'Content-Type': 'multipart/form-data',
},
});
expect(data).toBeInstanceOf(Blob);
const ndjson = await new Response(data).text();
expect(ndjson).toEqual(content);
});
it('should reject on network error', async () => {
expect.assertions(1);
fetchMock.get('*', { throws: new Error('Network issue') });
fetchMock.get('*', { status: 500 });
try {
await kfetch({ pathname: 'my/path' });
await kfetch({ pathname: '/my/path' });
} catch (e) {
expect(e.message).toBe('Network issue');
expect(e.message).toBe('Internal Server Error');
}
});
@ -126,7 +142,7 @@ describe('kfetch', () => {
beforeEach(async () => {
fetchMock.get('*', { status: 404, body: { foo: 'bar' } });
try {
await kfetch({ pathname: 'my/path' });
await kfetch({ pathname: '/my/path' });
} catch (e) {
error = e;
}
@ -154,7 +170,7 @@ describe('kfetch', () => {
fetchMock.get('*', { foo: 'bar' });
interceptorCalls = mockInterceptorCalls([{}, {}, {}]);
resp = await kfetch({ pathname: 'my/path' });
resp = await kfetch({ pathname: '/my/path' });
});
it('should call interceptors in correct order', () => {
@ -185,12 +201,12 @@ describe('kfetch', () => {
fetchMock.get('*', { foo: 'bar' });
interceptorCalls = mockInterceptorCalls([
{ requestError: () => ({}) },
{ requestError: () => ({ pathname: '/my/path' } as KFetchOptions) },
{ request: () => Promise.reject(new Error('Error in request')) },
{},
]);
resp = await kfetch({ pathname: 'my/path' });
resp = await kfetch({ pathname: '/my/path' });
});
it('should call interceptors in correct order', () => {
@ -227,7 +243,7 @@ describe('kfetch', () => {
]);
try {
await kfetch({ pathname: 'my/path' });
await kfetch({ pathname: '/my/path' });
} catch (e) {
error = e;
}
@ -267,7 +283,7 @@ describe('kfetch', () => {
]);
try {
await kfetch({ pathname: 'my/path' });
await kfetch({ pathname: '/my/path' });
} catch (e) {
error = e;
}
@ -313,7 +329,7 @@ describe('kfetch', () => {
{},
]);
resp = await kfetch({ pathname: 'my/path' });
resp = await kfetch({ pathname: '/my/path' });
});
it('should call in correct order', () => {
@ -351,7 +367,7 @@ describe('kfetch', () => {
}),
});
resp = await kfetch({ pathname: 'my/path' });
resp = await kfetch({ pathname: '/my/path' });
});
it('should modify request', () => {
@ -386,7 +402,7 @@ describe('kfetch', () => {
}),
});
resp = await kfetch({ pathname: 'my/path' });
resp = await kfetch({ pathname: '/my/path' });
});
it('should modify request', () => {
@ -453,6 +469,7 @@ function mockInterceptorCalls(interceptors: Interceptor[]) {
describe('withDefaultOptions', () => {
it('should remove undefined query params', () => {
const { query } = withDefaultOptions({
pathname: '/withDefaultOptions',
query: {
foo: 'bar',
param1: (undefined as any) as string,
@ -464,9 +481,10 @@ describe('withDefaultOptions', () => {
});
it('should add default options', () => {
expect(withDefaultOptions({})).toEqual({
expect(withDefaultOptions({ pathname: '/addDefaultOptions' })).toEqual({
pathname: '/addDefaultOptions',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'kbn-version': 'my-version' },
headers: { 'Content-Type': 'application/json' },
method: 'GET',
});
});

View file

@ -19,17 +19,18 @@
import { merge } from 'lodash';
// @ts-ignore not really worth typing
import { metadata } from 'ui/metadata';
import url from 'url';
import chrome from '../chrome';
import { KFetchError } from './kfetch_error';
import { HttpSetup } from '../../../../core/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { HttpRequestInit } from '../../../../core/public/http/types';
export interface KFetchQuery {
[key: string]: string | number | boolean | undefined;
}
export interface KFetchOptions extends RequestInit {
pathname?: string;
export interface KFetchOptions extends HttpRequestInit {
pathname: string;
query?: KFetchQuery;
}
@ -48,32 +49,21 @@ const interceptors: Interceptor[] = [];
export const resetInterceptors = () => (interceptors.length = 0);
export const addInterceptor = (interceptor: Interceptor) => interceptors.push(interceptor);
export async function kfetch(
options: KFetchOptions,
{ prependBasePath = true }: KFetchKibanaOptions = {}
) {
const combinedOptions = withDefaultOptions(options);
const promise = requestInterceptors(combinedOptions).then(
({ pathname, query, ...restOptions }) => {
const fullUrl = url.format({
pathname: prependBasePath ? chrome.addBasePath(pathname) : pathname,
query,
});
return window.fetch(fullUrl, restOptions).then(async res => {
if (!res.ok) {
throw new KFetchError(res, await getBodyAsJson(res));
}
const contentType = res.headers.get('content-type');
if (contentType && contentType.split(';')[0] === 'application/ndjson') {
return await getBodyAsBlob(res);
}
return await getBodyAsJson(res);
});
}
);
return responseInterceptors(promise);
export function createKfetch(http: HttpSetup) {
return function kfetch(
options: KFetchOptions,
{ prependBasePath = true }: KFetchKibanaOptions = {}
) {
return responseInterceptors(
requestInterceptors(withDefaultOptions(options))
.then(({ pathname, ...restOptions }) =>
http.fetch(pathname, { ...restOptions, prependBasePath })
)
.catch(err => {
throw new KFetchError(err.response || { statusText: err.message }, err.body);
})
);
};
}
// Request/response interceptors are called in opposite orders.
@ -91,36 +81,29 @@ function responseInterceptors(responsePromise: Promise<any>) {
}, responsePromise);
}
async function getBodyAsJson(res: Response) {
try {
return await res.json();
} catch (e) {
return null;
}
}
async function getBodyAsBlob(res: Response) {
try {
return await res.blob();
} catch (e) {
return null;
}
}
export function withDefaultOptions(options?: KFetchOptions): KFetchOptions {
return merge(
const withDefaults = merge(
{
method: 'GET',
credentials: 'same-origin',
headers: {
...(options && options.headers && options.headers.hasOwnProperty('Content-Type')
? {}
: {
'Content-Type': 'application/json',
}),
'kbn-version': metadata.version,
'Content-Type': 'application/json',
},
},
options
);
) as KFetchOptions;
if (
options &&
options.headers &&
'Content-Type' in options.headers &&
options.headers['Content-Type'] === undefined
) {
// TS thinks headers could be undefined here, but that isn't possible because
// of the merge above.
// @ts-ignore
withDefaults.headers['Content-Type'] = undefined;
}
return withDefaults;
}

View file

@ -1,39 +0,0 @@
/*
* 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.
*/
jest.mock('../chrome', () => ({
addBasePath: (path: string) => `http://localhost/myBase/${path}`,
}));
jest.mock('../metadata', () => ({
metadata: {
version: 'my-version',
},
}));
import { kfetchAbortable } from './kfetch_abortable';
describe('kfetchAbortable', () => {
it('should return an object with a fetching promise and an abort callback', () => {
const { fetching, abort } = kfetchAbortable({ pathname: 'my/path' });
expect(typeof fetching.then).toBe('function');
expect(typeof fetching.catch).toBe('function');
expect(typeof abort).toBe('function');
});
});

View file

@ -0,0 +1,38 @@
/*
* 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.
*/
/* eslint-disable @kbn/eslint/no-restricted-paths */
import { HttpService } from '../../core/public/http';
import { BasePathService } from '../../core/public/base_path';
import { fatalErrorsServiceMock } from '../../core/public/fatal_errors/fatal_errors_service.mock';
import { injectedMetadataServiceMock } from '../../core/public/injected_metadata/injected_metadata_service.mock';
/* eslint-enable @kbn/eslint/no-restricted-paths */
export function setup() {
const httpService = new HttpService();
const fatalErrors = fatalErrorsServiceMock.createSetupContract();
const injectedMetadata = injectedMetadataServiceMock.createSetupContract();
injectedMetadata.getBasePath.mockReturnValue('http://localhost/myBase');
const basePath = new BasePathService().setup({ injectedMetadata });
const http = httpService.setup({ basePath, fatalErrors, injectedMetadata });
return { httpService, fatalErrors, http };
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { kfetchAbortable } from 'ui/kfetch';
import { kfetch } from 'ui/kfetch';
import { SearchError, getSearchErrorType } from 'ui/courier';
function getAllFetchParams(searchRequests, Promise) {
@ -95,20 +95,18 @@ export const rollupSearchStrategy = {
failedSearchRequests,
} = await serializeAllFetchParams(allFetchParams, searchRequests);
const {
fetching,
abort,
} = kfetchAbortable({
const controller = new AbortController();
const promise = kfetch({
signal: controller.signal,
pathname: '../api/rollup/search',
method: 'POST',
body: serializedFetchParams,
});
return {
searching: new Promise((resolve, reject) => {
fetching.then(result => {
resolve(shimHitsInFetchResponse(result));
}).catch(error => {
searching: promise
.then(shimHitsInFetchResponse)
.catch(error => {
const {
body: { statusText, error: title, message },
res: { url },
@ -123,10 +121,9 @@ export const rollupSearchStrategy = {
type: getSearchErrorType({ message }),
});
reject(searchError);
});
}),
abort,
return Promise.reject(searchError);
}),
abort: () => controller.abort(),
failedSearchRequests,
};
},