homey-api
Version:
336 lines (293 loc) • 8.67 kB
JavaScript
/* eslint-disable no-undef */
;
const APIErrorTimeout = require('./APIErrorTimeout');
/**
* Helper Utility Class
* @class
* @private
* @hideconstructor
*/
class Util {
/**
* Makes a call using `window.fetch` or `node-fetch`.
* @param {...any} args
* @returns {Promise}
*/
static async fetch(...args) {
if (this.isReactNative()) {
return fetch(...args);
}
if (this.isBrowser()) {
return window.fetch(...args);
}
if (this.isNodeJS()) {
const fetch = require('node-fetch');
return fetch(...args);
}
if (typeof fetch !== 'undefined') {
return fetch(...args);
}
}
/**
* @param {number} ms - Number of milliseconds to wait
* @returns {Promise<void>}
*/
static async wait(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
/**
*
* @param {Promise} promise
* @param {number} [timeoutMillis=5000]
* @param {string} [message="Timeout after 5000ms"]
* @returns {Promise}
*/
static async timeout(promise, timeoutMillis = 5000, message = `Timeout after ${timeoutMillis}ms`) {
const timeoutError = new APIErrorTimeout(message);
let timeoutRef;
const returnPromise = Promise.race([
promise,
new Promise((_, reject) => {
timeoutRef = setTimeout(() => {
reject(timeoutError);
}, timeoutMillis);
}),
]);
returnPromise
// eslint-disable-next-line no-unused-vars
.catch(err => { })
.finally(() => {
clearTimeout(timeoutRef);
});
return returnPromise;
}
/**
* @returns {Function} Returns a function that when called, returns a number with the delta in ms.
*/
static benchmark() {
const start = new Date();
return () => {
const end = new Date();
return end - start;
};
}
/**
* Check if requests to http:// are supported.
* Websites served on https:// cannot talk to http:// due to security concerns.
* @returns {boolean}
*/
static isHTTPUnsecureSupported() {
if (this.isReactNative()) return true;
if (typeof window === 'undefined') return true;
if (typeof window.location === 'undefined') return false;
return window.location.protocol === 'http:';
}
/**
* @returns {boolean}
*/
static isReactNative() {
return (typeof navigator !== 'undefined' && navigator.product === 'ReactNative');
}
/**
* @returns {boolean}
*/
static isBrowser() {
if (this.isReactNative()) return false;
return (typeof document !== 'undefined' && typeof window.document !== 'undefined');
}
/**
* @returns {boolean}
*/
static isNodeJS() {
if (this.isReactNative()) return false;
return (typeof process !== 'undefined');
}
/**
* @param {string} name - Query parameter name
* @returns {string|null}
*/
static getSearchParameter(name) {
if (!this.isBrowser()) return null;
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get(name) || null;
}
/**
* This method encodes a string into a base64 string.
* It's provided as Util because Node.js uses `Buffer`,
* browsers use `btoa` and React Native doesn't provide anything.
* @param {string} input - Input
* @returns {string} - Base64 encoded output
*/
static base64(s) {
function btoaLookup(index) {
if (index >= 0 && index < 64) {
const keystr = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
return keystr[index];
}
// Throw INVALID_CHARACTER_ERR exception here -- won't be hit in the tests.
return undefined;
}
if (typeof s !== 'string') {
throw new Error('Invalid Input');
}
let i;
// "The btoa() method must throw an "InvalidCharacterError" DOMException if
// data contains any character whose code point is greater than U+00FF."
for (i = 0; i < s.length; i++) {
if (s.charCodeAt(i) > 255) {
return null;
}
}
let out = '';
for (i = 0; i < s.length; i += 3) {
const groupsOfSix = [undefined, undefined, undefined, undefined];
groupsOfSix[0] = s.charCodeAt(i) >> 2;
groupsOfSix[1] = (s.charCodeAt(i) & 0x03) << 4;
if (s.length > i + 1) {
groupsOfSix[1] |= s.charCodeAt(i + 1) >> 4;
groupsOfSix[2] = (s.charCodeAt(i + 1) & 0x0f) << 2;
}
if (s.length > i + 2) {
groupsOfSix[2] |= s.charCodeAt(i + 2) >> 6;
groupsOfSix[3] = s.charCodeAt(i + 2) & 0x3f;
}
for (let j = 0; j < groupsOfSix.length; j++) {
if (typeof groupsOfSix[j] === 'undefined') {
out += '=';
} else {
out += btoaLookup(groupsOfSix[j]);
}
}
}
return out;
}
/**
* Generates an UUID v4 string
* @returns {string} - UUID v4 string
*/
static uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0; const
v = c === 'x' ? r : ((r & 0x3) | 0x8);
return v.toString(16);
});
}
/**
* Get an environment variable.
* In Node.js, this is accesses from `process.env`.
* In a browser, this is accesses from `window.localStorage`.
* @returns {string} - UUID v4 string
*/
static env(key) {
if (this.isBrowser()) {
return window.localStorage.getItem(key) || null;
}
if (this.isNodeJS()) {
return process.env[key] || null;
}
if (this.isReactNative()) {
return null;
}
return null;
}
static envKey(key) {
return key
.replace('Athom', 'ATHOM_')
.replace('Homey', 'HOMEY_')
.replace('API', '_API')
.toUpperCase();
}
/**
* Polyfill for Promise.any, which is only supported on Node.js >=15
* @param {Array<Promise>} promises
*/
static async promiseAny(promises) {
if (promises.length === 0) return;
const rejections = [];
return new Promise((resolve, reject) => {
promises.forEach((promise, i) => {
promise
.then(result => resolve(result))
.catch(err => {
rejections[i] = err;
// Check if all promises have been rejected
if (rejections.filter(Boolean).length === promises.length) {
reject(rejections);
}
});
});
});
}
/**
* Converts an object to a query string
* @param {object} queryObject - Query parameter object
* @returns {string}
*/
static serializeQueryObject(queryObject) {
let prefix;
let querystring = [];
let rbracket = /\[\]$/;
function add(key, value) {
// If value is a function, invoke it and return its value.
value = (typeof value === 'function') ?
value() :
value === null ?
'' :
value;
querystring[querystring.length] = encodeURIComponent(key) +
'=' + encodeURIComponent(value);
}
function buildParams(prefix, obj, add) {
let name;
if (Array.isArray(obj)) {
// Serialize array item.
for (let index = 0; index < obj.length; index++) {
if (rbracket.test(prefix)) {
// Treat each array item as a scalar.
add(prefix, obj[index]);
} else {
// Item is non-scalar (array or object), encode its numeric index.
buildParams(prefix + '[' + (typeof (obj[index]) === 'object' ?
index :
''
) + ']', obj[index], add);
}
}
} else if (typeof obj === 'object') {
// Serialize object item.
for (name in obj) {
buildParams(prefix + '[' + name + ']', obj[name], add);
}
} else {
// Serialize scalar item.
add(prefix, obj);
}
}
// Encode params recursively.
for (prefix in queryObject) {
if (typeof queryObject[prefix] === 'undefined') continue;
buildParams(prefix, queryObject[prefix], add);
}
// Return the resulting serialization.
return querystring.join('&');
}
/**
* We use this instead of URLSearchParams because in react-native URLSearchParams are not encoded
* for some reason.
*
* @param {object} params
* @returns {string} encoded params
*/
static encodeUrlSearchParams(params) {
const encodedPairs = [];
for (const [key, value] of Object.entries(params)) {
const encodedKey = encodeURIComponent(key);
const encodedValue = encodeURIComponent(value);
encodedPairs.push(encodedKey + "=" + encodedValue);
}
return encodedPairs.join("&");
}
}
module.exports = Util;