import Aigle from "aigle"; import hash from "string-hash"; import util from "util"; import yn from "yn"; import { uniq, groupBy, get, isNil, isNaN, isArray, map, isPlainObject, mapValues, isObject } from "lodash"; import dataUriRegex from "data-uri-regex"; import urlRegex from "url-regex"; const isoDateValidators = require("graphql-iso-date/dist/utils/validator"); import { getLogger } from "../logging"; const logger = getLogger(); /** * Ensure required ENV variables are set during deployment. * * Default behavior is to throw an exception if environment variable missing * and defaultValue value not set. * * @param env * @param defaultValue */ export const requireEnv = (env: string, defaultValue: string = null) => { if (typeof process.env[env] !== "undefined") { return process.env[env]; } if (process.env.NODE_ENV !== "test" && defaultValue === null) { throw new Error(`Required ENV variable not set: ${env}`); } return defaultValue; }; /** * Parse npm package name and return base name, path and version in an object. */ export const parsePackageName = (input: string): { name: string; path: string; version: string } => { if (!input) { throw new Error("input required"); } const matched = input.charAt(0) === "@" ? input.match(/^(@[^/]+\/[^/@]+)(?:\/([^@]+))?(?:@([\s\S]+))?/) // scoped package name regex : input.match(/^([^/@]+)(?:\/([^@]+))?(?:@([\s\S]+))?/); // normal package name if (!matched) { throw new Error(`[parse-package-name] "${input}" is not a valid string`); } return { name: matched[1], path: matched[2] || "", version: matched[3] || "", }; }; /** * Deep object walk-through, accepts a function which allows to deeply * manipulate an object. * * @param obj: The object/array to iterate over. * @param mapFn: Callback function which will be invoked for each child of the object. * @param InstanceType: Object type to check instances. * @param key: Field name or array index of the value */ export const mapDeep = ( obj: T, mapFn: (value: any, key: string) => any, InstanceType?: any, key?: string ): T => { return isArray(obj) ? map(obj, (innerObj, idx) => mapDeep(innerObj, mapFn, InstanceType, idx)) : isPlainObject(obj) ? mapValues(obj, (val, key) => mapDeep(val, mapFn, InstanceType, key)) : isObject(obj) && InstanceType && obj instanceof InstanceType // check instance type; ? mapFn(obj, key) : isObject(obj) ? obj : mapFn(obj, key); }; /** * Async variant of `mapValuesDeep`. * * @param obj: The object/array to iterate over. * @param mapFn: Callback function which will be invoked for each child of the object. * @param InstanceType: Object type to check instances. * @param key: Field name or array index of the value */ export const mapDeepAsync = async ( obj: T, mapFn: (value: any, key: string) => any, InstanceType?: any, key?: string ): Promise => { return isArray(obj) ? Aigle.map(obj, (innerObj, idx) => mapDeepAsync(innerObj, mapFn, InstanceType, idx)) : isPlainObject(obj) ? Aigle.mapValues(obj, (val, key) => mapDeepAsync(val, mapFn, InstanceType, key)) : isObject(obj) && InstanceType && obj instanceof InstanceType // check instance type; ? mapFn(obj, key) : isObject(obj) ? obj : mapFn(obj, key); }; /** * Runs the provided function n times. * @param n * @param callback */ export const timesSync = (n: number, callback: () => T) => map(Array(n), callback); /** * Async variant of `timesSync`. * @param n * @param callback */ export const timesAsync = async (n: number, callback: () => void) => Promise.all(map(Array(n), callback)); /** * Runs the provided async function for each record of the input array. * @param items * @param cb */ export const forEachAsync = async (items: E[], cb: Function): Promise => Promise.all(items.map(async (i) => cb(i) as T)); /** * Async variant of filter. * @param data * @param fn */ export const filterAsync = async (data: any[], fn: (value: any, index: number) => Promise): Promise => { const results: boolean[] = await Promise.all(data.map(async (value, index) => fn(value, index))); return data.filter((d, i) => results[i]); }; /** * Run the provided async function in sequence using the array as input. * @param data data to work on * @param asyncFunc function that should be run in sequence on the provided data */ export const runInSequence = (data: [], asyncFunc: (..._: any) => Promise) => { return (data || []).reduce( (previous: any, current: any) => previous.then((acc: []) => asyncFunc(current).then( (result: any) => acc.concat(result), (err: any) => Promise.reject(err) ) ), Promise.resolve([]) ); }; /** * Whether the provided input is iterable. */ export const isIterable = (input: any): input is Iterable => { return input && typeof input[Symbol.iterator] === "function"; }; export const convertToBool = (value: string, fallback: boolean = false) => yn(value, { default: fallback }); export const isBool = (value: any) => yn(value); export const validateTime = (time: string): boolean => isoDateValidators.validateTime(time); export const validateDate = (dateString: string): boolean => isoDateValidators.validateDate(dateString); export const validateDateTime = (dateTimeString: string): boolean => isoDateValidators.validateDateTime(dateTimeString); /** * Returns an array with properties the project has that are not included in the provided 'keys'. */ export const differenceProps = (object: any, keys: string[]) => { return Object.keys(object).reduce((acc, k) => { if (!keys.includes(k)) { acc.push(k); } return acc; }, []); }; /** * Remove properties that don't have a value from the passed in object (mutates the object!) * @param obj */ export const removeEmptyProperties = (obj: any) => { Object.keys(obj).forEach((key) => { if (obj[key] && typeof obj[key] === "object") { removeEmptyProperties(obj[key]); if (Object.keys(obj[key]).length === 0) { delete obj[key]; } } else if (obj[key] == null) { delete obj[key]; } }); return obj; }; // TODO [Eelco] this hash function was lifted from pits, where there is also a comment that // the code should be put in a shared package. Not using the shared function in @pie-cli-libs/hash as // that seems different from the one used in pits (not sure if that would actually result in different // results, but I'd don't like to risk it). export const pieHash = (elements: string[]): string => { if (!elements) { return null; } const deps = elements.map((e) => parsePackageName(e)); const names = deps.map((d) => d.name); if (uniq(names).length !== names.length) { throw dependencyNamesNotUnique(deps); } const depstring = deps // TODO: latest allowed here? .map(({ name, version }) => `${name}@${version || "latest"}`) .sort((a, b) => a.localeCompare(b)) .join("+"); return String(hash(depstring)); }; const dependencyNamesNotUnique = (deps: { name: string; version: string; path: string }[]) => { return new Error(`dependency names not unique got: ${deps.map((d) => d.name)}`); }; /** * Print object's properties. */ export const printDeep = (obj: any, level: number = 8, showHidden: boolean = false): string => util.inspect(obj, showHidden, level); export const groupByKey = (collection: T[], key: K) => groupBy(collection, key); /** * Flatten the properties of an object one level, so e.g. { foo: { bar: 1, baz: 2 } } becomes { foo_bar: 1, foo_baz: 2 } */ export const singleLevelFlatten = (obj: { [key: string]: any }, separator: string = "_") => Object.entries(obj).reduce((acc, [parentName, parentObject]) => { Object.entries(parentObject).forEach(([childName, childObject]) => { if (isNaN(Number(childName)) && childName in parentObject) { acc[parentName + separator + childName] = childObject; } else { acc[parentName] = parentObject; } }); return acc; }, {}); /** * Safely decode a JSON string into an object. */ export const JSONParse = (str: string, fallback: any = null): any => { try { return JSON.parse(str); } catch (err) { logger.error("Could not decode object for string", str); } return fallback; }; /** * Gets the value at path of object. If the resolved value is undefined, * null or NaN, the defaultValue is returned in its place. * @param object * @param path * @param defaultValue */ export const safeGet = (object: any, path: string, defaultValue: any = null): any => { const value = get(object, path, defaultValue); return isNil(value) || isNaN(value) ? defaultValue : value; }; /** * Returns whether the string is a URL or not. * @param input */ export const isURL = (input: string): boolean => urlRegex({ exact: true }).test(input); /** * Returns whether the string is a data URL or not. * @param input */ export const isDataURL = (input: string): boolean => dataUriRegex().test(input);