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:
		| @@ -1,11 +1,11 @@ | |||||||
| import 'isomorphic-fetch'; |  | ||||||
| import log from 'loglevel'; | import log from 'loglevel'; | ||||||
|  | import Http from './util/http'; | ||||||
|  |  | ||||||
| log.setLevel('INFO'); | log.setLevel('INFO'); | ||||||
|  |  | ||||||
| export default class Keystone { | export default class Keystone { | ||||||
|  |  | ||||||
|   constructor(cloudConfig) { |   constructor (cloudConfig) { | ||||||
|     // Sanity checks. |     // Sanity checks. | ||||||
|     if (!cloudConfig) { |     if (!cloudConfig) { | ||||||
|       throw new Error('A configuration is required.'); |       throw new Error('A configuration is required.'); | ||||||
| @@ -13,12 +13,10 @@ export default class Keystone { | |||||||
|     // Clone the config, so that this instance is immutable |     // Clone the config, so that this instance is immutable | ||||||
|     // at runtime (no modifying the config after the fact). |     // at runtime (no modifying the config after the fact). | ||||||
|     this.cloudConfig = Object.assign({}, cloudConfig); |     this.cloudConfig = Object.assign({}, cloudConfig); | ||||||
|  |     this.http = new Http(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   authenticate() { |   authenticate() { | ||||||
|     const headers = { |  | ||||||
|       'Content-Type': 'application/json' |  | ||||||
|     }; |  | ||||||
|     const body = { |     const body = { | ||||||
|       auth: { |       auth: { | ||||||
|         identity: { |         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) => { |       .then((res) => { | ||||||
|         this.token = res.headers.get('X-Subject-Token'); |         this.token = res.headers.get('X-Subject-Token'); | ||||||
|         return res.json(); // This returns a promise... |         return res.json(); // This returns a promise... | ||||||
|   | |||||||
							
								
								
									
										174
									
								
								src/util/http.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								src/util/http.js
									
									
									
									
									
										Normal 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); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -60,7 +60,7 @@ describe('Openstack connection test', () => { | |||||||
|         username: 'user', |         username: 'user', | ||||||
|         password: 'pass', |         password: 'pass', | ||||||
|         project_name: 'js-openstack-lib', |         project_name: 'js-openstack-lib', | ||||||
|         auth_url: 'http://keystone' |         auth_url: 'http://keystone/' | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										210
									
								
								test/unit/util/httpTest.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								test/unit/util/httpTest.js
									
									
									
									
									
										Normal 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(); | ||||||
|  |       }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
		Reference in New Issue
	
	Block a user
	 Michael Krotscheck
					Michael Krotscheck