UNPKG

homebridge-vesync-v2

Version:

A Homebridge plugin for controlling VeSync smart devices including outlets, air purifiers, and humidifiers

695 lines (547 loc) 25.5 kB
'use strict'; const http = require('http'); const https = require('https'); const path = require('path').posix; const zlib = require('zlib'); const Transform = require('stream').Transform; const URL = require('url'); const MeterFactory = require('./MeterFactory'); const deepValue = require('deep-value'); const deepSet = require('deep-setter'); const defaultLogger = function (info) {}; class SubClient { constructor(config) { if (!config.parentClient) { throw new Error('needs parentClient'); } this._url = config.url || ''; this._headers = config.headers || {}; this._parentClient = config.parentClient; this._auditor = config.auditor; this._requestExtender = (typeof config.requestExtender === 'function') ? config.requestExtender : (r) => r; } opts(options){ return this._requestExtender(Object.assign({}, { auditor : this._auditor, headers : this._headers }, options)); } get(endpoint, options, callback, failure) { return this._parentClient.get(path.join(this._url, endpoint), this.opts(options), callback, failure); } post(endpoint, options, callback, failure) { return this._parentClient.post(path.join(this._url, endpoint), this.opts(options), callback, failure); } delete(endpoint, options, callback, failure) { return this._parentClient.delete(path.join(this._url, endpoint), this.opts(options), callback, failure); } put(endpoint, options, callback, failure) { return this._parentClient.put(path.join(this._url, endpoint), this.opts(options), callback, failure); } patch(endpoint, options, callback, failure) { return this._parentClient.patch(path.join(this._url, endpoint), this.opts(options), callback, failure); } } class HyperRequest { /** * @param opts - { * baseUrl : 'http://api.fixer.io/latest', * customLogger : function(){}, * retryOnFailure:{ * fail : function(){}, * min : 300. * max : 600, * retries : 5, * backOff : 10 //ms * }, * respondWithObject : true, //returns headers and request as well * respondWithProperty : 'data', //returns response property as top level, if set to false it returns full body * parserFunction : function(data){ return JSON.parse(data) } // optional ( defaults to JSON.parse * timeout : 4000, * maxCacheKeys : 10, * cacheTtl : 500, * enablePipe : false, * highWaterMark : 16000//set the high water mark on the transform stream * cacheByReference : false // if true cache returns back the object returned in itself, does not return a copy, thus is mutable * } */ constructor(config) { this.clearCache(); this.maxCacheKeys = typeof config.maxCacheKeys === 'number' ? config.maxCacheKeys : 100; this.cacheTtl = typeof config.cacheTtl === 'number' ? config.cacheTtl : 100; this.retryOnFail = !!config.retryOnFailure; this.retryFailureLogger = () => {}; this.retryMinCode = 400; this.retryMaxCode = 600; this.retryCount = 5; this.retryBackOff = 100; if (this.retryOnFail) { this.retryFailureLogger = config.retryOnFailure.fail || this.retryFailureLogger; this.retryMinCode = config.retryOnFailure.min || this.retryMinCode; this.retryMaxCode = config.retryOnFailure.max || this.retryMaxCode; this.retryCount = config.retryOnFailure.retries || this.retryCount; this.retryBackOff = config.retryOnFailure.backOff || this.retryBackOff; this.retryExtension = typeof config.retryOnFailure.retryExtension === 'function' ? config.retryOnFailure.retryExtension : () => { return Promise.resolve({ persist : false, extensions : [] }) }; } this.enablePipe = config.enablePipe; this.respondWithObject = config.respondWithObject === true; this.cacheByReference = config.cacheByReference; this.url = URL.parse(config.baseUrl); this.log = typeof config.customLogger === 'function' ? config.customLogger : defaultLogger; this.protocol = (config.protocol ? config.protocol : this.url.protocol) || 'http:'; this.baseUrl = this.url.hostname; this.baseEndpoint = this.url.path; this.port = config.port || this.url.port || (this.protocol.indexOf('https') > -1 ? '443' : '80'); this.keepAlive = (typeof config.keepAlive === 'boolean') ? config.keepAlive : true; let tmpAgent = (this.protocol === 'http:') ? new http.Agent({keepAlive: this.keepAlive}) : new https.Agent({keepAlive: this.keepAlive}); if(typeof config.agent !== 'undefined' && typeof config.agent !== 'boolean') { this.agent = config.agent; } else if(typeof config.agent === 'boolean' && config.agent) { this.agent = tmpAgent; } else { this.agent = false; } this.__extenderArray = []; this.parserFunction = config.parserFunction || JSON.parse; this.debug = typeof config.debug === 'boolean' ? config.debug : false; this.timeout = config.timeout || 60000; this.basicAuthToken = config.basicAuthToken; this.basicAuthSecret = config.basicAuthSecret; this.authorization = config.authorization; this.gzip = typeof config.gzip !== 'boolean' ? true : config.gzip; this.failWhenBadCode = typeof config.failWhenBadCode !== 'boolean' ? true : config.failWhenBadCode; this.auditor = (a, b, c) => { process.nextTick(typeof config.auditor === 'function' ? config.auditor : () => {}, a, b, c) }; this.cacheIgnoreFields = Array.isArray(config.cacheIgnoreFields) ? config.cacheIgnoreFields : []; this.respondWithProperty = typeof config.respondWithProperty !== 'boolean' ? (config.respondWithProperty || 'data') : false;//set to false if you want everything! this.headers = Object.assign({}, this.clone({ 'User-Agent': 'request', 'Content-Type': 'application/json', 'Accept': 'application/json', 'Accept-Encoding': this.gzip ? 'gzip, deflate' : undefined, Authorization: this.basicAuthToken ? ('Basic ' + (new Buffer(this.basicAuthToken + ':' + (this.basicAuthSecret ? this.basicAuthSecret : ''), 'utf8')).toString('base64')) : this.authorization?this.authorization:undefined }), config.headers); this._fireAndForget = typeof config.fireAndForget === 'boolean'?config.fireAndForget:false; } clearCache () { this.cache = {}; this.cacheKeys = []; } extendStream(resultant, Transformer) { Object.assign(resultant, Transformer); //hack! resultant.pipe = Transformer.pipe; resultant.once = Transformer.once; resultant.on = Transformer.on; resultant.resume = Transformer.resume; resultant.read = Transformer.read; resultant.write = Transformer.write; resultant._read = Transformer._read; resultant._write = Transformer._write; resultant.emit = Transformer.emit; resultant.removeListener = Transformer.removeListener; resultant.unpipe = Transformer.unpipe; resultant.pause = Transformer.pause; return resultant; } clone(data) { return data ? JSON.parse(JSON.stringify(data)) : data; } addCacheElement(key, value) { if (this.cacheTtl) { if (this.cacheKeys.length >= this.maxCacheKeys) { delete this.cache[this.cacheKeys.shift()]; } this.cacheKeys.push(key); this.cache[key] = { lastInvokeTimeout: setTimeout( () => { this.cacheKeys = this.cacheKeys.filter(testKey => testKey !== key); delete this.cache[key]; }, this.cacheTtl), value: this.clone(value) }; } return value; } getCacheElement(key) { let value = this.cache[key] ? this.cache[key].value : null; if (!this.cacheByReference) { value = this.clone(value); } return value; } //more perminant for this instance setHeader(key, value) { this.headers[key] = value; } getCookiesFromHeader(headers) { if (!headers || !headers.cookie) { return {}; } return headers.cookie.split(';').reduce((cookies, cookie) => { let parts = cookie.split('='); let key = parts.shift().trim(); if (key !== '') { cookies[key] = decodeURI(parts.join('=')); } return cookies; }, {}); } handleCallbackOrPromise(verb, endpoint, options, callback, failure) { if (typeof endpoint === 'undefined') { endpoint = ''; } if (typeof options === 'undefined') { options = {}; } if(Array.isArray(endpoint)){ return this.handleBulkBatch(verb, endpoint, options, callback, failure); } let res = this.makeRequest(verb, endpoint, options); return this.promiseOrCallbacks(res, options, callback, failure); } promiseOrCallbacks (promise, options, callback, failure) { if (typeof callback === 'function' && typeof failure === 'function') { return promise.then(callback, failure); } else if (typeof options === 'function' && typeof callback === 'function') { return promise.then(options, callback); } else if (typeof callback === 'function') { return promise.then((data) => { callback(null, data) }, (err) => { callback(err) }); } else { return promise; } } /** * handleBulkBatch * - bulk - meaning sending a bunch of requests possibly of different Urls but essentially at a batchSize of 1 * - batch - meaning an array of entities being sent to the same-ish url but sent via batching (splitting into batches of batchSize) * @param verb - [GET, POST, PUT, PATCH, DELETE, ETC] * @param endpoint - {Array} or strings or Objects with url property * @param options - opts all requests share * @param callback - callback * @param failure - failure callback if you want * @returns {*} - promise if no callbacks */ handleBulkBatch(verb, endpoint, options, callback, failure) { if(options.batch){ let batchSize = options.batchSize || 254; let batches = Math.ceil(endpoint.length/batchSize); let batchings = []; for(let i = 0; i < batchSize*batches; i+=batchSize) { let bodys = endpoint.slice(i, batchSize); batchings.push({ url : bodys[0].url, body : bodys.map(e => e.body) }); // batchResults.push(Promise.all(this.handleBulkBatch(verb, url, Object.assign({}, options, {batch : false, body : , callback, failure))); } let res = this.handleBulkBatch(verb, batchings, Object.assign({}, options, {batch : false})); return this.promiseOrCallbacks(res, options, callback, failure); } let timing = options.bulkDelay || 50; let doBackOffRequest = (endp, i) => { return new Promise((resolve, reject) => { setTimeout(() => { let url = ''; let opts = {}; if (typeof endp === 'object' && typeof endp.url === 'string') { url = endp.url; opts = Object.assign({}, endp, options); } else if (typeof endp === 'string') { url = endp; opts = Object.assign({}, options); } return this.handleCallbackOrPromise(verb, url, opts).then(resolve, reject); }, timing * i); }).then(data => data, err => Promise.resolve({ error: err, request: endp })); }; let resp = Promise.all(endpoint.map( (endp, i) => { return doBackOffRequest(endp, i); })); return this.promiseOrCallbacks(resp, options, callback, failure); } calcRequestOpts(verb, endpoint, opts, postData) { let requestOptions = { method: verb, protocol: opts.protocol || this.protocol, port: opts.port || this.port, host: opts.baseUrl || this.baseUrl, path: path.join(this.baseEndpoint || '', endpoint).replace('/?', '?'), timeout: opts.timeout || this.timeout, agent: (typeof opts.agent === 'boolean') ? opts.agent : this.agent }; if (opts.headers && typeof opts.headers === 'object') { requestOptions.headers = Object.assign({}, this.headers, opts.headers); } else { requestOptions.headers = this.headers; } if (postData) { requestOptions.headers = Object.assign({}, requestOptions.headers, { 'Content-Length': Buffer.byteLength(postData) }); } if (this.debug) { this.log('request opts', requestOptions); } return requestOptions; } retry(verb, endpoint, opts, retrysSoFar) { return new Promise((resolve, reject) => { return setTimeout(() => { this.makeRequest(verb, endpoint, opts).then(resolve, reject); }, this.retryBackOff * (retrysSoFar)); }); } failedDueToBadCode(statusCode) { return (this.failWhenBadCode && statusCode >= 400) && (this.retryMaxCode >= statusCode) && (this.retryMinCode <= statusCode); } setExtenders (array) { this.__extenderArray = Array.isArray(array)?array:this.__extenderArray; } getExtenders () { return this.__extenderArray; } extendByExtenders(opts, extenders) { (extenders||[]).forEach((extender) => { if(extender && typeof extender === 'object') { if(typeof extender.accessor === 'string' && typeof extender.value !== 'undefined') { deepSet(opts, extender.accessor, extender.value); } } }); return opts; } makeRequest(verb, endpoint, opts) { let meters = new MeterFactory(); let preparationMeter = meters.meter('preparation_meter'); if (!opts || typeof opts !== 'object') { opts = {}; } opts = this.extendByExtenders(opts, this.__extenderArray); if (this.retryOnFail && typeof opts.retriesAttempted !== 'number') { opts.retriesAttempted = 0; } if (this.debug) { this.log(verb, endpoint, new Date().getTime()); } const postData = typeof opts.body !== 'undefined' ? JSON.stringify(opts.body) : null; let requestOptions = this.calcRequestOpts(verb, endpoint, opts, postData); let tmpCacheKey = JSON.parse(JSON.stringify(Object.assign({}, requestOptions, {agent: 'cache'}))); this.cacheIgnoreFields.forEach(complexKey => { deepSet(tmpCacheKey, complexKey, undefined); }); let cacheKey = JSON.stringify(tmpCacheKey); if (this.cacheTtl) { let cacheValue = this.getCacheElement(cacheKey); if (cacheValue) { return new Promise((resolve) => { if (this.debug) { this.log(verb, endpoint, new Date().getTime()); } resolve(cacheValue); }); } } preparationMeter.end(); let responseData = []; const start = Date.now(); let timeStartResponse = null; let timeEndResponse = null; let firstChunk = meters.meter('first_chunk'); const Transformer = new Transform({ highWaterMark: (opts && typeof opts.highWaterMark === 'number') ? opts.highWaterMark : 16384 * 16, transform(chunk, enc, callback) { if(!timeStartResponse) { timeStartResponse = Date.now(); } firstChunk.end(); if (this.enablePipe) { callback(null, chunk); } else { responseData.push(chunk.toString('utf8')); callback(null, null); } } }); let resultant = new Promise((resolve, reject) => { let socketOpening = meters.meter('socket_opening'); const req = (requestOptions.protocol.indexOf('https') === -1 ? http : https).request(requestOptions, (response) => { socketOpening.end(); const startOfRequestTime = Date.now(); let requestFinished = meters.meter('response'); if (this.debug) { this.log(`request ${requestOptions.path} started @ ${startOfRequestTime}`); } if(this._fireAndForget) { return resolve(); } if ((typeof response.headers['content-encoding'] === 'string') && ['gzip', 'deflate'].indexOf(response.headers['content-encoding'].toLowerCase()) !== -1) { response.pipe(zlib.createUnzip()).pipe(Transformer); } else { response.pipe(Transformer); } Transformer.on('finish', () => { timeEndResponse = Date.now(); requestFinished.end(); let postProcess = meters.meter('post_process'); if (this.debug) { this.log(`request ${requestOptions.path} finished @ ${timeEndResponse}`); } let stringedResponse = responseData.join(''); let data = 'No Content'; let responseCookies = this.getCookiesFromHeader(response.headers); let startTimeDiff = startOfRequestTime - start; let responseTimeDiff = (timeStartResponse-startOfRequestTime); postProcess.end(); let extendedResponse = { statusCode : response.statusCode, // keep it compliant with other libraries code: response.statusCode, request: { agent: !!requestOptions.agent, cookies : this.getCookiesFromHeader(requestOptions.headers), baseUrl : this.baseUrl, headers : requestOptions.headers, method: requestOptions.method, protocol: requestOptions.protocol, port: requestOptions.port, host: requestOptions.baseUrl, path: requestOptions.path, timeout: requestOptions.timeout, postData : postData }, response : { statusCode : response.statusCode, headers : response.headers, cookies : responseCookies, size : stringedResponse.length }, timing : { start : startTimeDiff, response : responseTimeDiff, end :(timeEndResponse-timeStartResponse) }, metrics : meters.getMeters(), headers: response.headers, cookies: responseCookies, retries: opts.retriesAttempted }; let minData = stringedResponse?this.parserFunction(stringedResponse):data; let shouldReadIn = (!this.failedDueToBadCode(response.statusCode) && this.respondWithProperty); let dOrP = shouldReadIn ? deepValue(minData, this.respondWithProperty): minData; extendedResponse.body = dOrP; data = this.respondWithObject ? extendedResponse : dOrP; let localAuditor = (typeof opts.auditor === 'function'? opts.auditor:this.auditor); localAuditor(extendedResponse, data, response.headers);//allow you to override the auditor function on request if (this.failedDueToBadCode(response.statusCode)) { if (this.retryOnFail && (opts.retriesAttempted < this.retryCount)) { return this.retryExtension(extendedResponse).then((extenders) => { let isXtended = extenders && typeof extenders === 'object' && Array.isArray(extenders.extensions); if(isXtended && extenders.persist) { this.setExtenders(extenders.extensions); } let eOpts = this.extendByExtenders(opts, isXtended?extenders.extensions:null); return this.retry(verb, endpoint, eOpts, opts.retriesAttempted++).then(resolve, reject); }, (err) => { return reject(err || new Error('unknown error')); }); } process.nextTick(this.retryFailureLogger, data, response.headers); return reject(data || new Error('unknown error')); } resolve(this.addCacheElement(cacheKey, data)); }); Transformer.on('error', (err) => { if (this.debug) { this.log(`request ${requestOptions.path} errored @ ${Date.now()}`); } this.log('transform error', err); reject(err || new Error('unknown transform stream error')); }); Transformer.on('timeout', (err) => { if (this.debug) { this.log(`request ${requestOptions.path} timedout @ ${Date.now()}`); } this.log('transform error', err); reject(err || new Error('unknown transform stream error')); }); }); if(!this._fireAndForget) { req.on('error', function (err) { reject(err || new Error('error')); }); req.on('timeout', function (err) { reject(err || new Error('timeout')); }); } if (postData) { req.write(postData); } req.end(); }).catch((err) => { this.log("ERROR: " + err); }); if (!this._fireAndForget && typeof this.enablePipe === 'boolean' && this.enablePipe) { this.extendStream(resultant, Transformer); } return resultant; } get(endpoint, options, callback, failure) { return this.handleCallbackOrPromise('GET', endpoint, options, callback, failure); } post(endpoint, options, callback, failure) { return this.handleCallbackOrPromise('POST', endpoint, options, callback, failure); } delete(endpoint, options, callback, failure) { return this.handleCallbackOrPromise('DELETE', endpoint, options, callback, failure); } put(endpoint, options, callback, failure) { return this.handleCallbackOrPromise('PUT', endpoint, options, callback, failure); } patch(endpoint, options, callback, failure) { return this.handleCallbackOrPromise('PATCH', endpoint, options, callback, failure); } /** * * @param config * - url - extention to parents baseUrl * - headers - extension to parents headers * - auditor - override of parents auditor * - requestExtender - function which extends all requests * @returns {SubClient} */ child (config) { if(!config){ config = {}; } return new SubClient({ url : config.url, headers : config.headers, auditor : config.auditor, requestExtender : config.requestExtender, parentClient : this }); } }; module.exports = HyperRequest;