'use strict';
/**
* Defines a client to connect to REST web service.
*
* @module openveo-rest-nodejs-client/RestClient
*/
const url = require('url');
const fs = require('fs');
const path = require('path');
const errors = process.requireRestClient('lib/errors/index.js');
const Request = process.requireRestClient('lib/Request.js');
const RequestError = errors.RequestError;
const AuthenticationError = errors.AuthenticationError;
/**
* Rejects all requests with the given error.
*
* If the request is running, it will be aborted.
*
* @private
* @static
* @memberof module:openveo-rest-nodejs-client/RestClient~RestClient
* @param {Set} requests The list of requests to reject
* @param {Error} error The reject's error
*/
function rejectAll(requests, error) {
for (const request of requests) {
request.abort();
request.reject(error);
}
}
class RestClient {
/**
* Creates a client to connect to REST web service.
*
* It aims to facilitate implementation of a REST web service client. Requesting an end point, without being
* authenticated, will automatically execute the *authenticateRequest* first before calling the end point.
* If token expired, a new authentication is made automatically.
*
* You MUST:
* - Extend this class
* - Define a *authenticateRequest* property with a Request as a value. This will be automatically called by
* the RestClient to get an access token from the server (response from server should contain the property
* *access_token* containing the access token which will be stored in RestClient *accessToken* property and used
* for all subsequent requests). Use *buildRequest* function to create the authenticate request
* - Make sure that the web service server returns a property *error_description* set to "Token not found or expired"
* if token couln't be retrieved
* - Make sure that the web service server returns a property *error_description* set to "Token already expired"
* if token has expired
*
* You MAY:
* - Override function *getAuthenticationHeaders*. By default the list of headers returned by
* *getAuthenticationHeaders* function will be added to all requests sent to the server. One of this header may be
* the authentication header for example
*
* @class RestClient
* @constructor
* @param {String} webServiceUrl The complete URL of the REST web service (with protocol and port)
* @param {String} [certificate] Absolute path to the web service server full chain certificate file
* @throws {TypeError} Thrown if webServiceUrl is not a valid String
*/
constructor(webServiceUrl, certificate) {
if (!webServiceUrl || typeof webServiceUrl !== 'string')
throw new TypeError(`Invalid web service url : ${webServiceUrl}`);
// Parse web service url to get protocol, host and port
const serverUrl = new url.URL(webServiceUrl);
const protocol = serverUrl.protocol === 'https:' ? 'https' : 'http';
const port = parseInt(serverUrl.port) || (protocol === 'http' ? 80 : 443);
Object.defineProperties(this,
/** @lends module:openveo-rest-nodejs-client/Request~Request */
{
/**
* Web service protocol, either "http" or "https".
*
* @type {String}
* @readonly
* @instance
*/
protocol: {value: protocol, enumerable: true},
/**
* Web service server host name.
*
* @type {String}
* @readonly
* @instance
*/
hostname: {value: serverUrl.hostname, enumerable: true},
/**
* Web service server port.
*
* @type {Number}
* @readonly
* @instance
*/
port: {value: port, enumerable: true},
/**
* Web service URL path.
*
* @type {String}
* @readonly
* @instance
*/
path: {value: serverUrl.pathname, enumerable: true},
/**
* Application access token provided by the web service.
*
* @type {String}
* @instance
*/
accessToken: {value: null, writable: true, enumerable: true},
/**
* Path to the web service server certificate file.
*
* @type {String}
* @readonly
* @instance
*/
certificate: {value: certificate, enumerable: true},
/**
* The collection of queued requests waiting to be executed.
*
* @type {Set}
* @instance
*/
queuedRequests: {writable: true, value: new Set(), enumerable: true},
/**
* Maximum number of authentication attempts to perform on a request in case of an invalid or expired token.
*
* @type {Number}
* @default 1
* @instance
*/
maxAuthenticationAttempts: {value: 1, writable: true, enumerable: true}
}
);
}
/**
* Executes a GET request.
*
* If client is not authenticated or access token has expired, a new authentication is automatically
* performed.
*
* @async
* @param {String} endPoint The web service end point to reach with query parameters
* @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
* @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
* without limits
* @return {Promise} Promise resolving with result as an Object
* @throws {TypeError} Thrown if endPoint is not valid a String
*/
get(endPoint, options, timeout) {
return this.executeRequest('get', endPoint, options, null, timeout);
}
/**
* Executes a POST request.
*
* If client is not authenticated or access token has expired, a new authentication is automatically
* performed.
*
* @async
* @param {String} endPoint The web service end point to reach with query parameters
* @param {(Object|String)} [body] The request body
* @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
* @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
* without limits
* @param {Boolean} [multiparted=false] true to send body as multipart/form-data
* @return {Promise} Promise resolving with results as an Object
* @throws {TypeError} Thrown if endPoint is not valid a String
*/
post(endPoint, body, options, timeout, multiparted) {
return this.executeRequest('post', endPoint, options, body, timeout, multiparted);
}
/**
* Executes a PATCH request.
*
* If client is not authenticated or access token has expired, a new authentication is automatically
* performed.
*
* @async
* @param {String} endPoint The web service end point to reach with query parameters
* @param {(Object|String)} [body] The request body
* @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
* @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
* without limits
* @param {Boolean} [multiparted=false] true to send body as multipart/form-data
* @return {Promise} Promise resolving with results as an Object
* @throws {TypeError} Thrown if endPoint is not valid a String
*/
patch(endPoint, body, options, timeout, multiparted) {
return this.executeRequest('patch', endPoint, options, body, timeout, multiparted);
}
/**
* Executes a PUT request.
*
* If client is not authenticated or access token has expired, a new authentication is automatically
* performed.
*
* @async
* @param {String} endPoint The web service end point to reach with query parameters
* @param {(Object|String)} [body] The request body
* @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
* @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
* without limits
* @param {Boolean} [multiparted=false] true to send body as multipart/form-data
* @return {Promise} Promise resolving with results as an Object
* @throws {TypeError} Thrown if endPoint is not valid a String
*/
put(endPoint, body, options, timeout, multiparted) {
return this.executeRequest('put', endPoint, options, body, timeout, multiparted);
}
/**
* Executes a DELETE request.
*
* If client is not authenticated or access token has expired, a new authentication is automatically
* performed.
*
* @async
* @param {String} endPoint The web service end point to reach with query parameters
* @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
* @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
* without limits
* @return {Promise} Promise resolving with results as an Object
* @throws {TypeError} Thrown if endPoint is not valid a String
*/
delete(endPoint, options, timeout) {
return this.executeRequest('delete', endPoint, options, null, timeout);
}
/**
* Executes a REST request after making sure the client is authenticated.
*
* If client is not authenticated or access token has expired, a new authentication is automatically
* performed and request is retried.
*
* @async
* @ignore
* @param {String} method The HTTP method to use (either get, post, delete or put)
* @param {String} endPoint The web service end point to reach with query parameters
* @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
* @param {(Object|String)} [body] The request body
* @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
* without limits
* @param {Boolean} [multiparted=false] true to send body as multipart/form-data
* @return {Promise} Promise resolving with request's response
* @throws {TypeError} Thrown if method or endPoint is not a valid String
*/
executeRequest(method, endPoint, options, body, timeout, multiparted) {
return new Promise((resolve, reject) => {
endPoint = `${this.path}/${endPoint}`.replace(/^\/+/, '');
options = options || {};
// Merge options with default options
options = Object.assign({
path: `/${endPoint}`,
method: method.toUpperCase(),
headers: {}
}, options);
// Merge headers with default headers
options.headers = Object.assign(
{
'Content-Type': 'application/json',
Accept: 'application/json'
},
options.headers
);
// Remove Content-Type header if multiparted, form-data will generate this header for us
if (multiparted) delete options.headers['Content-Type'];
this.queuedRequests.add(this.buildRequest(options, body, timeout, multiparted, resolve, reject));
this.authenticateAndExecute();
});
}
/**
* Indicates if the client is authenticated to the web service or not.
*
* @ignore
* @return {Boolean} true if the client is authenticated, false otherwise
*/
isAuthenticated() {
return this.accessToken ? true : false;
}
/**
* Gets the list of headers to send with each request.
*
* @ignore
* @return {Object} The list of headers to add to all requests sent to the server
*/
getRequestHeaders() {
return {};
}
/**
* Authenticates the client to the web service.
*
* @ignore
* @async
* @return {Promise} Promise resolving when the client is authenticated, promise is rejected if authentication
* failed
*/
authenticate() {
return new Promise((resolve, reject) => {
// Already authenticated
if (this.isAuthenticated())
resolve();
else {
// Not authenticated
// Authenticate to the web service
this.authenticateRequest.execute().then((result) => {
if (result.error)
reject(new AuthenticationError(result.error_description));
else if (!result.access_token)
reject(new AuthenticationError('Invalid token'));
else {
this.accessToken = result.access_token;
resolve();
}
}).catch((error) => {
reject(error);
});
}
});
}
/**
* Authenticates client to the web service and execute all queued requests.
*
* @ignore
*/
authenticateAndExecute() {
/**
* Interprets response results to get a human readable error message.
*
* @param {Object} result Web service response with an eventually error property and an httpCode property
* @param {Request} request The request associated to the result
* @return {String|Null} The error message
*/
const getErrorMessage = (result, request) => {
const options = request.options;
if (result.error || result.httpCode >= 400) {
if (result.httpCode === 403)
return `You don't have the authorization to access the endpoint "${options.method} ${options.path}"`;
else if (result.httpCode === 401)
return 'Authentication failed, verify your credentials';
else if (result.httpCode === 404)
return `Resource ${options.path} not found`;
else if (result.error) {
const error = result.error;
const message = error.message || '';
return `Error: "${message}" (code=${error.code}, module=${error.module})`;
} else
return 'Unkown error';
}
return null;
};
if (!this.authenticateRequest.isRunning) {
// Authenticate to the web service
this.authenticate().then(() => {
// Client is now authenticated to the web service
// Execute all queued requests
this.queuedRequests.forEach((request) => {
if (request.isRunning || this.authenticateRequest.isRunning) return;
request.execute(this.getAuthenticationHeaders()).then((result) => {
// Request done (meaning that transfer worked)
if (result.error || result.httpCode >= 400) {
if (result.error_description && (result.error_description === 'Token not found or expired' ||
result.error_description === 'Token already expired')) {
// Token has expired, authenticate and try again
// If still on error, after the maximum authentication attempts, reject the request
this.accessToken = null;
// Max attempts reached for this request, reject
if (request.attempts >= this.maxAuthenticationAttempts) {
this.queuedRequests.delete(request);
request.reject(new RequestError('Max attempts reached', result.httpCode));
} else {
request.attempts++;
this.authenticateAndExecute();
}
} else {
// An error has been returned by the web service
// Reject the request with the error
this.queuedRequests.delete(request);
request.reject(new RequestError(getErrorMessage(result, request), result.httpCode));
}
} else {
// Everything went fine
// Resolve with the results
this.queuedRequests.delete(request);
request.resolve(result);
}
}).catch((error) => {
// Request failed
// Reject the request
this.queuedRequests.delete(request);
request.reject(error);
});
});
}).catch((error) => {
// Authentication failed
// Reject and abort all queued requests with the same error and clear the queue
rejectAll(this.queuedRequests, error);
this.queuedRequests.clear();
});
}
}
/**
* Builds a request.
*
* @ignore
* @param {Object} [options] The list of http(s) options as described by NodeJS http.request documentation
* @param {(Object|String)} [body] The request body
* @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
* without limits
* @param {Boolean} [multiparted=false] true to send body as multipart/form-data
* @param {Function} resolve The function to call with request's result
* @param {Function} reject The function to call if request fails
* @return {Request} The request, ready to be executed
*/
buildRequest(options, body, timeout, multiparted, resolve, reject) {
options = Object.assign({
hostname: this.hostname,
port: this.port
}, options);
// Add web service certificate as a trusted certificate
if (this.certificate && this.protocol === 'https') {
/* eslint node/no-sync: 0 */
options = Object.assign({
ca: fs.readFileSync(path.normalize(this.certificate))
}, options);
}
const request = new Request(this.protocol, options, body, timeout, multiparted);
request.resolve = resolve;
request.reject = reject;
return request;
}
/**
* Gets the list of headers to send with each request.
*
* @return {Object} The list of headers to add to all requests sent to the server
*/
getAuthenticationHeaders() {
return {};
}
}
module.exports = RestClient;