Created HTTP wrapper

This utility class provides an abstraction layer for HTTP calls via fetch(). Its purpose is
to provide common, SDK-wide behavior for all HTTP requests. Included are:

- Providing a common extension point for request and response manipulation.
- Access to default headers.

In the future, this class chould also be extended to provide:

- Some form of progress() support for large uploads and downloads (perhaps via introduction of Q)
- Convenience decoding of the response body, depending on Content-Type.
- Internal error handling (At this time, HTTP errors are passed to then() rather than catch()).
- Other features.

Change-Id: I8173554b1b7ef052e2a74504b98f4ec574ce6136
This commit is contained in:
Michael Krotscheck 2016-08-15 11:46:56 -07:00
parent e9dd0fe10a
commit abd0508c30
4 changed files with 389 additions and 12 deletions

View File

@ -1,11 +1,11 @@
import 'isomorphic-fetch';
import log from 'loglevel';
import Http from './util/http';
log.setLevel('INFO');
export default class Keystone {
constructor(cloudConfig) {
constructor (cloudConfig) {
// Sanity checks.
if (!cloudConfig) {
throw new Error('A configuration is required.');
@ -13,12 +13,10 @@ export default class Keystone {
// Clone the config, so that this instance is immutable
// at runtime (no modifying the config after the fact).
this.cloudConfig = Object.assign({}, cloudConfig);
this.http = new Http();
}
authenticate() {
const headers = {
'Content-Type': 'application/json'
};
const body = {
auth: {
identity: {
@ -32,13 +30,8 @@ export default class Keystone {
}
}
};
const init = {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
};
return fetch(this.cloudConfig.auth.auth_url, init)
return this.http.httpPost(this.cloudConfig.auth.auth_url, body)
.then((res) => {
this.token = res.headers.get('X-Subject-Token');
return res.json(); // This returns a promise...

174
src/util/http.js Normal file
View File

@ -0,0 +1,174 @@
/*
* Copyright (c) 2016 Hewlett Packard Enterprise Development L.P.
*
* Licensed 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 'isomorphic-fetch';
/**
* This utility class provides an abstraction layer for HTTP calls via fetch(). Its purpose is
* to provide common, SDK-wide behavior for all HTTP requests. Included are:
*
* - Providing a common extension point for request and response manipulation.
* - Access to default headers.
*
* In the future, this class chould also be extended to provide:
*
* - Some form of progress() support for large uploads and downloads (perhaps via introduction of Q)
* - Convenience decoding of the response body, depending on Content-Type.
* - Internal error handling (At this time, HTTP errors are passed to then() rather than catch()).
* - Other features.
*/
export default class Http {
/**
* The list of active request interceptors for this instance. You may modify this list to
* adjust how your responses are processed. Each interceptor will be passed the Request
* instance, which must be returned from the interceptor either directly, or via a promise.
*
* @returns {Array} An array of all request interceptors.
*/
get requestInterceptors () {
return this._requestInterceptors;
}
/**
* The list of active response interceptors for this instance. Each interceptor will be passed
* the raw (read-only) Response instance, which should be returned from the interceptor either
* directly, or via a promise.
*
* @returns {Array} An array of all response interceptors.
*/
get responseInterceptors () {
return this._responseInterceptors;
}
/**
* The default headers which will be sent with every request. A copy of these headers will be
* added to the Request instance passed through the interceptor chain, and may be
* modified there.
*
* @returns {{string: string}} A mapping of 'headerName': 'headerValue'
*/
get defaultHeaders () {
return this._defaultHeaders;
}
/**
* Create a new HTTP handler.
*/
constructor () {
this._requestInterceptors = [];
this._responseInterceptors = [];
// Add default response interceptors.
this._defaultHeaders = {
'Content-Type': 'application/json'
};
}
/**
* Make a decorated HTTP request.
*
* @param {String} method The HTTP method.
* @param {String} url The request URL.
* @param {{}} headers A map of HTTP headers.
* @param {{}} body The body. It will be JSON-Encoded by the handler.
* @returns {Promise} A promise which will resolve with the processed request response.
*/
httpRequest (method, url, headers = {}, body) {
// Sanitize the headers...
headers = Object.assign({}, headers, this.defaultHeaders);
// Build the request
const init = {method, headers};
// The Request() constructor will throw an error if the method is GET/HEAD, and there's a body.
if (['GET', 'HEAD'].indexOf(method) === -1 && body) {
init.body = JSON.stringify(body);
}
const request = new Request(url, init);
let promise = Promise.resolve(request);
// Loop through the request interceptors, constructing a promise chain.
for (let interceptor of this.requestInterceptors) {
promise = promise.then(interceptor);
}
// Make the actual request...
promise = promise
.then((request) => {
// Deconstruct the request, since fetch-mock doesn't actually support fetch(Request);
const init = {
method: request.method,
headers: request.headers
};
if (['GET', 'HEAD'].indexOf(request.method) === -1 && request.body) {
init.body = request.body;
}
return fetch(request.url, init);
});
// Pass the response content through the response interceptors...
for (let interceptor of this.responseInterceptors) {
promise = promise.then(interceptor);
}
return promise;
}
/**
* Make a raw GET request against a particular URL.
*
* @param {String} url The request URL.
* @returns {Promise} A promise which will resolve with the processed request response.
*/
httpGet (url) {
return this.httpRequest('GET', url, {}, null);
}
/**
* Make a raw PUT request against a particular URL.
*
* @param {String} url The request URL.
* @param {{}} body The body. It will be JSON-Encoded by the handler.
* @returns {Promise} A promise which will resolve with the processed request response.
*/
httpPut (url, body) {
return this.httpRequest('PUT', url, {}, body);
}
/**
* Make a raw POST request against a particular URL.
*
* @param {String} url The request URL.
* @param {{}} body The body. It will be JSON-Encoded by the handler.
* @returns {Promise} A promise which will resolve with the processed request response.
*/
httpPost (url, body) {
return this.httpRequest('POST', url, {}, body);
}
/**
* Make a raw DELETE request against a particular URL.
*
* @param {String} url The request URL.
* @returns {Promise} A promise which will resolve with the processed request response.
*/
httpDelete (url) {
return this.httpRequest('DELETE', url, {}, null);
}
}

View File

@ -60,7 +60,7 @@ describe('Openstack connection test', () => {
username: 'user',
password: 'pass',
project_name: 'js-openstack-lib',
auth_url: 'http://keystone'
auth_url: 'http://keystone/'
}
};

210
test/unit/util/httpTest.js Normal file
View File

@ -0,0 +1,210 @@
/*
* Copyright (c) 2016 Hewlett Packard Enterprise Development L.P.
*
* Licensed 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 Http from '../../../src/util/http.js';
import fetchMock from 'fetch-mock';
describe('Http', () => {
let http;
const testUrl = 'https://example.com/';
const testRequest = {lol: 'cat'};
const testResponse = {foo: 'bar'};
beforeEach(() => {
http = new Http();
});
afterEach(fetchMock.restore);
it("should permit manually constructing requests", (done) => {
fetchMock.get(testUrl, testResponse);
http.httpRequest('GET', testUrl)
.then((response) => response.json())
.then((body) => {
expect(fetchMock.called(testUrl)).toBe(true);
expect(body).toEqual(testResponse);
done();
})
.catch((error) => {
expect(error).toBeNull();
done();
});
});
it("should make GET requests", (done) => {
fetchMock.get(testUrl, testResponse);
http.httpGet(testUrl)
.then((response) => response.json())
.then((body) => {
expect(fetchMock.called(testUrl)).toBe(true);
expect(body).toEqual(testResponse);
done();
})
.catch((error) => {
expect(error).toBeNull();
done();
});
});
it("should make PUT requests", (done) => {
fetchMock.put(testUrl, testResponse, testRequest);
http.httpPut(testUrl, testRequest)
.then((response) => response.json())
.then((body) => {
expect(fetchMock.called(testUrl)).toEqual(true);
expect(body).toEqual(testResponse);
done();
})
.catch((error) => {
expect(error).toBeNull();
done();
});
});
it("should make POST requests", (done) => {
fetchMock.post(testUrl, testResponse, testRequest);
http.httpPost(testUrl, testRequest)
.then((response) => response.json())
.then((body) => {
expect(fetchMock.called(testUrl)).toEqual(true);
expect(body).toEqual(testResponse);
done();
})
.catch((error) => {
expect(error).toBeNull();
done();
});
});
it("should make DELETE requests", (done) => {
fetchMock.delete(testUrl, testRequest);
http.httpDelete(testUrl, testRequest)
.then(() => {
expect(fetchMock.called(testUrl)).toEqual(true);
done();
})
.catch((error) => {
expect(error).toBeNull();
done();
});
});
it("should permit setting default headers", (done) => {
http.defaultHeaders['Custom-Header'] = 'Custom-Value';
fetchMock.get(testUrl, testResponse);
http.httpGet(testUrl)
.then(() => {
let headers = fetchMock.lastOptions().headers;
expect(headers.get('custom-header')).toEqual('Custom-Value');
done();
})
.catch((error) => {
expect(error).toBeNull();
done();
});
});
it("should permit request interception", (done) => {
fetchMock.get(testUrl, testResponse);
http.requestInterceptors.push((request) => {
request.headers.direct = true;
return request;
});
http.requestInterceptors.push((request) => {
request.headers.promise = true;
return Promise.resolve(request);
});
http.httpGet(testUrl)
.then(() => {
let options = fetchMock.lastOptions();
expect(options.headers.direct).toEqual(true);
expect(options.headers.promise).toEqual(true);
done();
})
.catch((error) => {
expect(error).toBeNull();
done();
});
});
it("should permit response interception", (done) => {
fetchMock.get(testUrl, testResponse);
http.responseInterceptors.push((response) => {
response.headers.direct = true;
return response;
});
http.responseInterceptors.push((response) => {
response.headers.promise = true;
return Promise.resolve(response);
});
http.httpGet(testUrl)
.then((response) => {
expect(response.headers.direct).toEqual(true);
expect(response.headers.promise).toEqual(true);
done();
})
.catch((error) => {
expect(error).toBeNull();
done();
});
});
it("should pass exceptions back to the invoker", (done) => {
fetchMock.get(testUrl, () => {
throw new TypeError(); // Example- net::ERR_NAME_NOT_RESOLVED
});
http.httpGet(testUrl)
.then((response) => {
// We shouldn't reach this point.
expect(response).toBeNull();
done();
})
.catch((error) => {
expect(error.stack).toBeDefined();
done();
});
});
it("should pass failed requests back to the invoker", (done) => {
fetchMock.get(testUrl, {status: 500, body: testResponse});
http.httpGet(testUrl)
.then((response) => {
// The HTTP request 'succeeded' with a failing state.
expect(response.status).toEqual(500);
return response.json();
})
.then((body) => {
expect(body).toEqual(testResponse);
done();
})
.catch((error) => {
expect(error).toBeNull();
done();
});
});
});