Source: RestClient.js

  1. 'use strict';
  2. /**
  3. * Defines a client to connect to REST web service.
  4. *
  5. * @module openveo-rest-nodejs-client/RestClient
  6. */
  7. const url = require('url');
  8. const fs = require('fs');
  9. const path = require('path');
  10. const errors = process.requireRestClient('lib/errors/index.js');
  11. const Request = process.requireRestClient('lib/Request.js');
  12. const RequestError = errors.RequestError;
  13. const AuthenticationError = errors.AuthenticationError;
  14. /**
  15. * Rejects all requests with the given error.
  16. *
  17. * If the request is running, it will be aborted.
  18. *
  19. * @private
  20. * @static
  21. * @memberof module:openveo-rest-nodejs-client/RestClient~RestClient
  22. * @param {Set} requests The list of requests to reject
  23. * @param {Error} error The reject's error
  24. */
  25. function rejectAll(requests, error) {
  26. for (const request of requests) {
  27. request.abort();
  28. request.reject(error);
  29. }
  30. }
  31. class RestClient {
  32. /**
  33. * Creates a client to connect to REST web service.
  34. *
  35. * It aims to facilitate implementation of a REST web service client. Requesting an end point, without being
  36. * authenticated, will automatically execute the *authenticateRequest* first before calling the end point.
  37. * If token expired, a new authentication is made automatically.
  38. *
  39. * You MUST:
  40. * - Extend this class
  41. * - Define a *authenticateRequest* property with a Request as a value. This will be automatically called by
  42. * the RestClient to get an access token from the server (response from server should contain the property
  43. * *access_token* containing the access token which will be stored in RestClient *accessToken* property and used
  44. * for all subsequent requests). Use *buildRequest* function to create the authenticate request
  45. * - Make sure that the web service server returns a property *error_description* set to "Token not found or expired"
  46. * if token couln't be retrieved
  47. * - Make sure that the web service server returns a property *error_description* set to "Token already expired"
  48. * if token has expired
  49. *
  50. * You MAY:
  51. * - Override function *getAuthenticationHeaders*. By default the list of headers returned by
  52. * *getAuthenticationHeaders* function will be added to all requests sent to the server. One of this header may be
  53. * the authentication header for example
  54. *
  55. * @class RestClient
  56. * @constructor
  57. * @param {String} webServiceUrl The complete URL of the REST web service (with protocol and port)
  58. * @param {String} [certificate] Absolute path to the web service server full chain certificate file
  59. * @throws {TypeError} Thrown if webServiceUrl is not a valid String
  60. */
  61. constructor(webServiceUrl, certificate) {
  62. if (!webServiceUrl || typeof webServiceUrl !== 'string')
  63. throw new TypeError(`Invalid web service url : ${webServiceUrl}`);
  64. // Parse web service url to get protocol, host and port
  65. const serverUrl = new url.URL(webServiceUrl);
  66. const protocol = serverUrl.protocol === 'https:' ? 'https' : 'http';
  67. const port = parseInt(serverUrl.port) || (protocol === 'http' ? 80 : 443);
  68. Object.defineProperties(this,
  69. /** @lends module:openveo-rest-nodejs-client/Request~Request */
  70. {
  71. /**
  72. * Web service protocol, either "http" or "https".
  73. *
  74. * @type {String}
  75. * @readonly
  76. * @instance
  77. */
  78. protocol: {value: protocol, enumerable: true},
  79. /**
  80. * Web service server host name.
  81. *
  82. * @type {String}
  83. * @readonly
  84. * @instance
  85. */
  86. hostname: {value: serverUrl.hostname, enumerable: true},
  87. /**
  88. * Web service server port.
  89. *
  90. * @type {Number}
  91. * @readonly
  92. * @instance
  93. */
  94. port: {value: port, enumerable: true},
  95. /**
  96. * Web service URL path.
  97. *
  98. * @type {String}
  99. * @readonly
  100. * @instance
  101. */
  102. path: {value: serverUrl.pathname, enumerable: true},
  103. /**
  104. * Application access token provided by the web service.
  105. *
  106. * @type {String}
  107. * @instance
  108. */
  109. accessToken: {value: null, writable: true, enumerable: true},
  110. /**
  111. * Path to the web service server certificate file.
  112. *
  113. * @type {String}
  114. * @readonly
  115. * @instance
  116. */
  117. certificate: {value: certificate, enumerable: true},
  118. /**
  119. * The collection of queued requests waiting to be executed.
  120. *
  121. * @type {Set}
  122. * @instance
  123. */
  124. queuedRequests: {writable: true, value: new Set(), enumerable: true},
  125. /**
  126. * Maximum number of authentication attempts to perform on a request in case of an invalid or expired token.
  127. *
  128. * @type {Number}
  129. * @default 1
  130. * @instance
  131. */
  132. maxAuthenticationAttempts: {value: 1, writable: true, enumerable: true}
  133. }
  134. );
  135. }
  136. /**
  137. * Executes a GET request.
  138. *
  139. * If client is not authenticated or access token has expired, a new authentication is automatically
  140. * performed.
  141. *
  142. * @async
  143. * @param {String} endPoint The web service end point to reach with query parameters
  144. * @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
  145. * @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
  146. * without limits
  147. * @return {Promise} Promise resolving with result as an Object
  148. * @throws {TypeError} Thrown if endPoint is not valid a String
  149. */
  150. get(endPoint, options, timeout) {
  151. return this.executeRequest('get', endPoint, options, null, timeout);
  152. }
  153. /**
  154. * Executes a POST request.
  155. *
  156. * If client is not authenticated or access token has expired, a new authentication is automatically
  157. * performed.
  158. *
  159. * @async
  160. * @param {String} endPoint The web service end point to reach with query parameters
  161. * @param {(Object|String)} [body] The request body
  162. * @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
  163. * @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
  164. * without limits
  165. * @param {Boolean} [multiparted=false] true to send body as multipart/form-data
  166. * @return {Promise} Promise resolving with results as an Object
  167. * @throws {TypeError} Thrown if endPoint is not valid a String
  168. */
  169. post(endPoint, body, options, timeout, multiparted) {
  170. return this.executeRequest('post', endPoint, options, body, timeout, multiparted);
  171. }
  172. /**
  173. * Executes a PATCH request.
  174. *
  175. * If client is not authenticated or access token has expired, a new authentication is automatically
  176. * performed.
  177. *
  178. * @async
  179. * @param {String} endPoint The web service end point to reach with query parameters
  180. * @param {(Object|String)} [body] The request body
  181. * @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
  182. * @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
  183. * without limits
  184. * @param {Boolean} [multiparted=false] true to send body as multipart/form-data
  185. * @return {Promise} Promise resolving with results as an Object
  186. * @throws {TypeError} Thrown if endPoint is not valid a String
  187. */
  188. patch(endPoint, body, options, timeout, multiparted) {
  189. return this.executeRequest('patch', endPoint, options, body, timeout, multiparted);
  190. }
  191. /**
  192. * Executes a PUT request.
  193. *
  194. * If client is not authenticated or access token has expired, a new authentication is automatically
  195. * performed.
  196. *
  197. * @async
  198. * @param {String} endPoint The web service end point to reach with query parameters
  199. * @param {(Object|String)} [body] The request body
  200. * @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
  201. * @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
  202. * without limits
  203. * @param {Boolean} [multiparted=false] true to send body as multipart/form-data
  204. * @return {Promise} Promise resolving with results as an Object
  205. * @throws {TypeError} Thrown if endPoint is not valid a String
  206. */
  207. put(endPoint, body, options, timeout, multiparted) {
  208. return this.executeRequest('put', endPoint, options, body, timeout, multiparted);
  209. }
  210. /**
  211. * Executes a DELETE request.
  212. *
  213. * If client is not authenticated or access token has expired, a new authentication is automatically
  214. * performed.
  215. *
  216. * @async
  217. * @param {String} endPoint The web service end point to reach with query parameters
  218. * @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
  219. * @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
  220. * without limits
  221. * @return {Promise} Promise resolving with results as an Object
  222. * @throws {TypeError} Thrown if endPoint is not valid a String
  223. */
  224. delete(endPoint, options, timeout) {
  225. return this.executeRequest('delete', endPoint, options, null, timeout);
  226. }
  227. /**
  228. * Executes a REST request after making sure the client is authenticated.
  229. *
  230. * If client is not authenticated or access token has expired, a new authentication is automatically
  231. * performed and request is retried.
  232. *
  233. * @async
  234. * @ignore
  235. * @param {String} method The HTTP method to use (either get, post, delete or put)
  236. * @param {String} endPoint The web service end point to reach with query parameters
  237. * @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
  238. * @param {(Object|String)} [body] The request body
  239. * @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
  240. * without limits
  241. * @param {Boolean} [multiparted=false] true to send body as multipart/form-data
  242. * @return {Promise} Promise resolving with request's response
  243. * @throws {TypeError} Thrown if method or endPoint is not a valid String
  244. */
  245. executeRequest(method, endPoint, options, body, timeout, multiparted) {
  246. return new Promise((resolve, reject) => {
  247. endPoint = `${this.path}/${endPoint}`.replace(/^\/+/, '');
  248. options = options || {};
  249. // Merge options with default options
  250. options = Object.assign({
  251. path: `/${endPoint}`,
  252. method: method.toUpperCase(),
  253. headers: {}
  254. }, options);
  255. // Merge headers with default headers
  256. options.headers = Object.assign(
  257. {
  258. 'Content-Type': 'application/json',
  259. Accept: 'application/json'
  260. },
  261. options.headers
  262. );
  263. // Remove Content-Type header if multiparted, form-data will generate this header for us
  264. if (multiparted) delete options.headers['Content-Type'];
  265. this.queuedRequests.add(this.buildRequest(options, body, timeout, multiparted, resolve, reject));
  266. this.authenticateAndExecute();
  267. });
  268. }
  269. /**
  270. * Indicates if the client is authenticated to the web service or not.
  271. *
  272. * @ignore
  273. * @return {Boolean} true if the client is authenticated, false otherwise
  274. */
  275. isAuthenticated() {
  276. return this.accessToken ? true : false;
  277. }
  278. /**
  279. * Gets the list of headers to send with each request.
  280. *
  281. * @ignore
  282. * @return {Object} The list of headers to add to all requests sent to the server
  283. */
  284. getRequestHeaders() {
  285. return {};
  286. }
  287. /**
  288. * Authenticates the client to the web service.
  289. *
  290. * @ignore
  291. * @async
  292. * @return {Promise} Promise resolving when the client is authenticated, promise is rejected if authentication
  293. * failed
  294. */
  295. authenticate() {
  296. return new Promise((resolve, reject) => {
  297. // Already authenticated
  298. if (this.isAuthenticated())
  299. resolve();
  300. else {
  301. // Not authenticated
  302. // Authenticate to the web service
  303. this.authenticateRequest.execute().then((result) => {
  304. if (result.error)
  305. reject(new AuthenticationError(result.error_description));
  306. else if (!result.access_token)
  307. reject(new AuthenticationError('Invalid token'));
  308. else {
  309. this.accessToken = result.access_token;
  310. resolve();
  311. }
  312. }).catch((error) => {
  313. reject(error);
  314. });
  315. }
  316. });
  317. }
  318. /**
  319. * Authenticates client to the web service and execute all queued requests.
  320. *
  321. * @ignore
  322. */
  323. authenticateAndExecute() {
  324. /**
  325. * Interprets response results to get a human readable error message.
  326. *
  327. * @param {Object} result Web service response with an eventually error property and an httpCode property
  328. * @param {Request} request The request associated to the result
  329. * @return {String|Null} The error message
  330. */
  331. const getErrorMessage = (result, request) => {
  332. const options = request.options;
  333. if (result.error || result.httpCode >= 400) {
  334. if (result.httpCode === 403)
  335. return `You don't have the authorization to access the endpoint "${options.method} ${options.path}"`;
  336. else if (result.httpCode === 401)
  337. return 'Authentication failed, verify your credentials';
  338. else if (result.httpCode === 404)
  339. return `Resource ${options.path} not found`;
  340. else if (result.error) {
  341. const error = result.error;
  342. const message = error.message || '';
  343. return `Error: "${message}" (code=${error.code}, module=${error.module})`;
  344. } else
  345. return 'Unkown error';
  346. }
  347. return null;
  348. };
  349. if (!this.authenticateRequest.isRunning) {
  350. // Authenticate to the web service
  351. this.authenticate().then(() => {
  352. // Client is now authenticated to the web service
  353. // Execute all queued requests
  354. this.queuedRequests.forEach((request) => {
  355. if (request.isRunning || this.authenticateRequest.isRunning) return;
  356. request.execute(this.getAuthenticationHeaders()).then((result) => {
  357. // Request done (meaning that transfer worked)
  358. if (result.error || result.httpCode >= 400) {
  359. if (result.error_description && (result.error_description === 'Token not found or expired' ||
  360. result.error_description === 'Token already expired')) {
  361. // Token has expired, authenticate and try again
  362. // If still on error, after the maximum authentication attempts, reject the request
  363. this.accessToken = null;
  364. // Max attempts reached for this request, reject
  365. if (request.attempts >= this.maxAuthenticationAttempts) {
  366. this.queuedRequests.delete(request);
  367. request.reject(new RequestError('Max attempts reached', result.httpCode));
  368. } else {
  369. request.attempts++;
  370. this.authenticateAndExecute();
  371. }
  372. } else {
  373. // An error has been returned by the web service
  374. // Reject the request with the error
  375. this.queuedRequests.delete(request);
  376. request.reject(new RequestError(getErrorMessage(result, request), result.httpCode));
  377. }
  378. } else {
  379. // Everything went fine
  380. // Resolve with the results
  381. this.queuedRequests.delete(request);
  382. request.resolve(result);
  383. }
  384. }).catch((error) => {
  385. // Request failed
  386. // Reject the request
  387. this.queuedRequests.delete(request);
  388. request.reject(error);
  389. });
  390. });
  391. }).catch((error) => {
  392. // Authentication failed
  393. // Reject and abort all queued requests with the same error and clear the queue
  394. rejectAll(this.queuedRequests, error);
  395. this.queuedRequests.clear();
  396. });
  397. }
  398. }
  399. /**
  400. * Builds a request.
  401. *
  402. * @ignore
  403. * @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
  404. * @param {(Object|String)} [body] The request body
  405. * @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
  406. * without limits
  407. * @param {Boolean} [multiparted=false] true to send body as multipart/form-data
  408. * @param {Function} resolve The function to call with request's result
  409. * @param {Function} reject The function to call if request fails
  410. * @return {Request} The request, ready to be executed
  411. */
  412. buildRequest(options, body, timeout, multiparted, resolve, reject) {
  413. options = Object.assign({
  414. hostname: this.hostname,
  415. port: this.port
  416. }, options);
  417. // Add web service certificate as a trusted certificate
  418. if (this.certificate && this.protocol === 'https') {
  419. /* eslint node/no-sync: 0 */
  420. options = Object.assign({
  421. ca: fs.readFileSync(path.normalize(this.certificate))
  422. }, options);
  423. }
  424. const request = new Request(this.protocol, options, body, timeout, multiparted);
  425. request.resolve = resolve;
  426. request.reject = reject;
  427. return request;
  428. }
  429. /**
  430. * Gets the list of headers to send with each request.
  431. *
  432. * @return {Object} The list of headers to add to all requests sent to the server
  433. */
  434. getAuthenticationHeaders() {
  435. return {};
  436. }
  437. }
  438. module.exports = RestClient;