/*
* (c) Miva Inc <https://www.miva.com/>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* $Id: client.js 74093 2019-03-13 20:38:09Z gidriss $
*/
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const util = require('./util');
const abstract = require('./abstract');
const requests = require('./requests');
const { MultiCallRequest, MultiCallOperation } = require('./multicall');
/** @module Client */
/** SIGN_DIGEST constants */
/** @ignore */
const SIGN_DIGEST_SHA1 = 'sha1';
/** @ignore */
const SIGN_DIGEST_SHA256 = 'sha256';
/** @ignore */
const SIGN_DIGEST_NONE = '';
/**
* Handles sending API requests
* @see https://docs.miva.com/json-api/#authentication
*/
class Client {
/**
* Client Constructor.
* @param {URL|string} endpoint
* @param {string} apiToken
* @param {string} signingKey
* @param {Object} options
*/
constructor(endpoint, apiToken, signingKey, options = {}) {
this.setEndpoint(endpoint);
this.setApiToken(apiToken);
this.setSigningKey(signingKey);
this.options = Object.assign({
require_timestamps: true,
signing_key_digest: 'sha256',
default_store_code: null,
}, options);
}
/**
* Constant SIGN_DIGEST_SHA1
* @constant
* @returns {string}
*/
static get SIGN_DIGEST_SHA1() {
return SIGN_DIGEST_SHA1;
}
/**
* Constant SIGN_DIGEST_SHA256
* @constant
* @returns {string}
*/
static get SIGN_DIGEST_SHA256() {
return SIGN_DIGEST_SHA256;
}
/**
* Constant SIGN_DIGEST_NONE
* @constant
* @returns {string}
*/
static get SIGN_DIGEST_NONE() {
return SIGN_DIGEST_NONE;
}
/**
* Get the API endpoint URL.
* @returns {URL}
*/
getEndpoint() {
return this.endpoint;
}
/**
* Set the API endpoint URL.
* @param {URL|string} endpoint
* @returns {Client}
*/
setEndpoint(endpoint) {
var proto;
if (util.isInstanceOf(endpoint, URL)) {
this.endpoint = endpoint;
} else {
this.endpoint = new URL(endpoint);
}
proto = this.endpoint.protocol.toLowerCase();
if (proto !== 'http:' && proto !== 'https:') {
throw new Error(util.format('Invalid protocol %s. Expected http(s).', proto));
}
return this;
}
/**
* Get the api token used to authenticate the request.
* @returns {string}
*/
getApiToken() {
return this.apiToken;
}
/**
* Set the api token used to authenticate the request.
* @param {string} apiToken
* @returns {Client}
*/
setApiToken(apiToken) {
this.apiToken = apiToken;
return this;
}
/**
* Get the signing key used to sign requests. Base64 encoded.
* @returns {string}
*/
getSigningKey() {
return this.signingKey;
}
/**
* Set the signing key used to sign requests. Base64 encoded.
* @param {string} signingKey
* @returns {Client}
*/
setSigningKey(signingKey) {
this.signingKey = signingKey;
return this;
}
/**
* Get a client option.
* @param {string} key
* @returns {*}
*/
getOption(key) {
return this.options[key];
}
/**
* Set a client option.
* @param {string} key
* @param {*} value
* @returns {Client}
*/
setOption(key, value) {
this.options[key] = value;
return this;
}
/**
* Queue a Request object for a Promise.
* @param {Request} request
* @returns {Promise}
*/
promise(request) {
var self = this;
return new Promise(function onResolvePromise(resolve, reject) {
self.send(request, function onRequestComplete(response, error) {
if (error) {
reject(error);
} else {
resolve(response);
}
});
});
}
/**
* Send a Request object with callback.
* @param {Request} request
* @param {Client~sendCallback} callback
* @throws {Error} When an invalid callback is supplied
* @returns {void}
*/
send(request, callback) {
var data;
var i;
var l;
var i2;
var l2;
var mrequests;
var orequests;
var defaultStore = this.getOption('default_store_code');
if (!util.isFunction(callback)) {
throw new Error('Expecting a function callback');
} else if (!util.isInstanceOf(request, abstract.Request)) {
callback(new Error('Expecting instance of Request'), null);
}
if (util.isInstanceOf(request, MultiCallRequest)) {
mrequests = request.getRequests();
for (i = 0, l = mrequests.length; i < l; i++) {
if (util.isInstanceOf(mrequests[i], MultiCallOperation)) {
orequests = mrequests[i].getRequests();
for (i2 = 0, l2 = orequests.length; i2 < l2; i2++) {
if (orequests[i2].getScope() == abstract.Request.REQUEST_SCOPE_STORE &&
!util.isNullOrUndefined(orequests[i2].getStoreCode()) &&
!util.isNullOrUndefined(defaultStore) &&
orequests[i2].getScope() != defaultStore) {
orequests[i2].setStoreCode(defaultStore);
}
}
} else {
if (mrequests[i].getScope() == abstract.Request.REQUEST_SCOPE_STORE &&
util.isNullOrUndefined(mrequests[i].getStoreCode()) &&
!util.isNullOrUndefined(defaultStore)) {
mrequests[i].setStoreCode(defaultStore);
}
}
}
data = request.toObject();
} else {
if (request.getScope() == abstract.Request.REQUEST_SCOPE_STORE &&
util.isNullOrUndefined(request.getStoreCode()) &&
!util.isNullOrUndefined(defaultStore)) {
request.setStoreCode(defaultStore);
}
data = Object.assign(request.toObject(), { Function: request.getFunction() });
}
if (!util.isObject(data)) {
callback(new Error(util.format('Expected an Object but but a %s', typeof data)), null);
}
if (this.options.require_timestamps) {
data['Miva_Request_Timestamp'] = Math.floor(Date.now() / 1000);
}
this.sendLowLevel(data, function onJsonResponse(error, json) {
var response;
if (error) {
callback(error, null);
} else {
try {
response = request.createResponse(json);
} catch(e) {
callback(e, null);
}
if (!util.isInstanceOf(response, abstract.Response)) {
callback(new Error('Request object did not return a Response object'), response);
} else {
callback(null, response);
}
}
});
}
/**
* The callback signature for Client~send
* @callback Client~sendCallback
* @param {?Error} error
* @param {?Response} response
*/
/**
* Send an low level API request with callback.
* @param {Object} data
* @param {Client~sendLowLevelCallback} callback
* @returns {http.ClientRequest|https.ClientRequest}
*/
sendLowLevel(data, callback) {
var body;
var options;
var proto;
if (this.endpoint.protocol.toLowerCase() === 'https:') {
proto = https;
} else {
proto = http;
}
body = JSON.stringify(data);
// Keep compatibility with Node version less than 10.9.0
options = {
hostname: this.endpoint.hostname,
port: this.endpoint.port,
path: this.endpoint.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body, 'utf8'),
'X-Miva-API-Authorization': this.generateAuthHeader(body)
}
};
const request = proto.request(options, function onPrepareResponse(response) {
var json;
var content = '';
response.setEncoding('utf8');
response.on('data', function onChunkReceived(chunk) {
content += chunk;
}).on('end', function onResponseEnd() {
try {
json = JSON.parse(content);
} catch (e) {
callback(new Error(e.message), null);
return;
}
callback(null, json);
})
}).on('error', function onRequestError(error) {
callback(error, null);
});
request.write(body);
request.end();
return request;
}
/**
* The callback signature for Client~sendLowLevel
* @callback Client~sendLowLevelCallback
* @param {?Error} error
* @param {?Object} response
*/
/**
* Generates the authentication header value.
* @param {string} data
* @returns {string}
*/
generateAuthHeader(data) {
var key;
var hash;
var digest = this.getOption('signing_key_digest');
if (digest !== SIGN_DIGEST_SHA1 && digest !== SIGN_DIGEST_SHA256) {
return util.format('MIVA %s', this.getApiToken());
}
key = Buffer.from(this.getSigningKey(), 'base64');
if (!key.byteLength) {
throw new Error('No signing key assigned to sign request');
}
hash = crypto.createHmac(digest, key).update(data);
return util.format('MIVA-HMAC-%s %s:%s', digest.toUpperCase(), this.getApiToken(), hash.digest('base64'));
}
/**
* Create a Request object by name.
* @param {string} name
* @param {?Model} model
* @throws {Error}
*/
createRequest(name, model = null) {
if (name.indexOf('_') !== -1) {
name = name.replace(/_/g, '');
}
if (name in requests && util.isFunction(requests[name])) {
return new requests[name](this, model);
}
throw new Error(util.format('Request %s Not Found', name));
}
}
module.exports = {
Client
};