Skip to content

Commit

Permalink
Add kfetch interceptor
Browse files Browse the repository at this point in the history
  • Loading branch information
sorenlouv committed Aug 17, 2018
1 parent 42c291a commit d4e17b3
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 51 deletions.
41 changes: 41 additions & 0 deletions src/ui/public/kfetch/default_interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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';
// @ts-ignore not really worth typing
import { metadata } from 'ui/metadata';
import { Interceptor } from './kfetch';

export const defaultInterceptor: Interceptor = {
request: (config: any) => {
return merge(
{
method: 'GET',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'kbn-version': metadata.version,
},
},
config
);
},

response: (res: any) => res.json(),
};
30 changes: 30 additions & 0 deletions src/ui/public/kfetch/fetch_error.ts
Original file line number Diff line number Diff line change
@@ -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 FetchError extends Error {
constructor(public readonly res: Response, public readonly body?: any) {
super(res.statusText);

// 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, FetchError);
}
}
}
100 changes: 96 additions & 4 deletions src/ui/public/kfetch/kfetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

jest.mock('../chrome', () => ({
addBasePath: (path: string) => `myBase/${path}`,
addBasePath: (path: string) => `http://localhost.com/myBase/${path}`,
}));

jest.mock('../metadata', () => ({
Expand All @@ -28,7 +28,7 @@ jest.mock('../metadata', () => ({
}));

import fetchMock from 'fetch-mock';
import { kfetch } from './kfetch';
import { _resetInterceptors, interceptors, kfetch } from './kfetch';

describe('kfetch', () => {
const matcherName: any = /my\/path/;
Expand All @@ -45,7 +45,7 @@ describe('kfetch', () => {

it('should prepend with basepath by default', async () => {
await kfetch({ pathname: 'my/path', query: { a: 'b' } });
expect(fetchMock.lastUrl(matcherName)).toBe('myBase/my/path?a=b');
expect(fetchMock.lastUrl(matcherName)).toBe('http://localhost.com/myBase/my/path?a=b');
});

it('should not prepend with basepath when disabled', async () => {
Expand Down Expand Up @@ -104,8 +104,100 @@ describe('kfetch', () => {
}).catch(e => {
expect(e.message).toBe('Not Found');
expect(e.res.status).toBe(404);
expect(e.res.url).toBe('myBase/my/path?a=b');
expect(e.res.url).toBe('http://localhost.com/myBase/my/path?a=b');
});
});
});

describe('interceptors', () => {
afterEach(() => {
fetchMock.restore();
_resetInterceptors();
});

it('response: should modify response via interceptor', async () => {
fetchMock.get(matcherName, new Response(JSON.stringify({ foo: 'bar' })));
interceptors.push({
response: res => {
return {
...res,
addedByInterceptor: true,
};
},
});

const resp = await kfetch({ pathname: 'my/path' });
expect(resp).toEqual({
addedByInterceptor: true,
foo: 'bar',
});
});

it('request: should add headers via interceptor', async () => {
fetchMock.get(matcherName, new Response(JSON.stringify({ foo: 'bar' })));
interceptors.push({
request: config => {
return {
...config,
headers: {
...config.headers,
addedByInterceptor: true,
},
};
},
});

await kfetch({
pathname: 'my/path',
headers: { myHeader: 'foo' },
});

expect(fetchMock.lastOptions(matcherName)).toEqual({
method: 'GET',
credentials: 'same-origin',
headers: {
addedByInterceptor: true,
myHeader: 'foo',
'Content-Type': 'application/json',
'kbn-version': 'my-version',
},
});
});

it('responseError: should throw custom error', () => {
fetchMock.get(matcherName, {
status: 404,
});

interceptors.push({
responseError: e => {
throw new Error('my custom error');
},
});

const resp = kfetch({
pathname: 'my/path',
});

expect(resp).rejects.toThrow('my custom error');
});

it('responseError: should swallow error', async () => {
fetchMock.get(matcherName, {
status: 404,
});

interceptors.push({
responseError: e => {
return 'resolved valued';
},
});

const resp = kfetch({
pathname: 'my/path',
});

expect(resp).resolves.toBe('resolved valued');
});
});
});
106 changes: 60 additions & 46 deletions src/ui/public/kfetch/kfetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,74 +18,88 @@
*/

import 'isomorphic-fetch';
import { merge } from 'lodash';
import url from 'url';
import chrome from '../chrome';
import { defaultInterceptor } from './default_interceptor';
import { FetchError } from './fetch_error';

// @ts-ignore not really worth typing
import { metadata } from '../metadata';

class FetchError extends Error {
constructor(public readonly res: Response, public readonly body?: any) {
super(res.statusText);

// 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, FetchError);
}
}
interface KFetchQuery {
[key: string]: string | number | boolean;
}

export interface KFetchOptions extends RequestInit {
pathname?: string;
query?: { [key: string]: string | number | boolean };
query?: KFetchQuery;
}

export interface KFetchKibanaOptions {
prependBasePath?: boolean;
}

export function kfetch(fetchOptions: KFetchOptions, kibanaOptions?: KFetchKibanaOptions) {
// fetch specific options with defaults
const { pathname, query, ...combinedFetchOptions } = merge(
{
method: 'GET',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'kbn-version': metadata.version,
},
},
fetchOptions
);
export interface Interceptor {
request?: (config: any) => any;
requestError?: (e: any) => any;
response?: (res: any) => any;
responseError?: (e: any) => any;
}

// kibana specific options with defaults
const combinedKibanaOptions = {
prependBasePath: true,
...kibanaOptions,
};
export const interceptors: Interceptor[] = [defaultInterceptor];
export function _resetInterceptors() {
interceptors.length = 0;
interceptors.push(defaultInterceptor);
}

export async function kfetch(
options: KFetchOptions,
{ prependBasePath = true }: KFetchKibanaOptions = {}
) {
const { pathname, query, ...restOptions } = await successInterceptors(options, 'request');
const fullUrl = url.format({
pathname: combinedKibanaOptions.prependBasePath ? chrome.addBasePath(pathname) : pathname,
pathname: prependBasePath ? chrome.addBasePath(pathname) : pathname,
query,
});

const fetching = new Promise<any>(async (resolve, reject) => {
const res = await fetch(fullUrl, combinedFetchOptions);
let res;
try {
res = await fetch(fullUrl, restOptions);
} catch (e) {
return errorInterceptors(e, 'requestError');
}

if (res.ok) {
return successInterceptors(res, 'response');
}

if (res.ok) {
return resolve(await res.json());
const fetchError = new FetchError(res, getBodyAsJson(res));
return errorInterceptors(fetchError, 'responseError');
}

function successInterceptors(res: any, name: 'request' | 'response') {
return interceptors.reduce((acc, interceptor) => {
const fn = interceptor[name];
if (!fn) {
return acc;
}

try {
// attempt to read the body of the response
return reject(new FetchError(res, await res.json()));
} catch (_) {
// send FetchError without the body if we are not be able to read the body for some reason
return reject(new FetchError(res));
return acc.then(fn);
}, Promise.resolve(res));
}

function errorInterceptors(e: Error, name: 'requestError' | 'responseError') {
return interceptors.reduce((acc, interceptor) => {
const fn = interceptor[name];
if (!fn) {
return acc;
}
});

return fetching;
return acc.catch(fn);
}, Promise.reject(e));
}

async function getBodyAsJson(res: Response) {
try {
return await res.json();
} catch (e) {
return null;
}
}
2 changes: 1 addition & 1 deletion src/ui/public/kfetch/kfetch_abortable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

jest.mock('../chrome', () => ({
addBasePath: (path: string) => `myBase/${path}`,
addBasePath: (path: string) => `http://localhost.com/myBase/${path}`,
}));

jest.mock('../metadata', () => ({
Expand Down

0 comments on commit d4e17b3

Please sign in to comment.