diff --git a/src/keystone.js b/src/keystone.js index 6693473..a9ca3bd 100644 --- a/src/keystone.js +++ b/src/keystone.js @@ -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... diff --git a/src/util/http.js b/src/util/http.js new file mode 100644 index 0000000..5242fb4 --- /dev/null +++ b/src/util/http.js @@ -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); + } +} diff --git a/test/unit/keystoneTest.js b/test/unit/keystoneTest.js index 57593a3..0644738 100644 --- a/test/unit/keystoneTest.js +++ b/test/unit/keystoneTest.js @@ -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/' } }; diff --git a/test/unit/util/httpTest.js b/test/unit/util/httpTest.js new file mode 100644 index 0000000..9d4649f --- /dev/null +++ b/test/unit/util/httpTest.js @@ -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(); + }); + }); +});