lib/util/utils.ts (522 lines of code) (raw):

/* eslint-disable no-bitwise */ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. import { execSync } from "child_process"; import * as fs from "fs"; import * as http from "http"; import * as path from "path"; import * as util from "util"; import * as jsonPointer from "json-pointer"; import * as YAML from "js-yaml"; import * as lodash from "lodash"; import { cloneDeep, Data, mapEntries, MutableStringMap, StringMap, parseMarkdown, readFile, } from "@azure-tools/openapi-tools-common"; import * as amd from "@azure/openapi-markdown"; import * as commonmark from "commonmark"; import { log } from "./logging"; /* * Executes an array of promises sequentially. Inspiration of this method is here: * https://pouchdb.com/2015/05/18/we-have-a-problem-with-promises.html. An awesome blog on promises! * * @param {Array} promiseFactories An array of promise factories(A function that return a promise) * * @return A chain of resolved or rejected promises */ export async function executePromisesSequentially<T>( promiseFactories: ReadonlyArray<() => Promise<T>> ): Promise<readonly T[]> { const result: T[] = []; for (const promiseFactory of promiseFactories) { result.push(await promiseFactory()); } return result; } export interface Reference { readonly filePath?: string; readonly localReference?: LocalReference; } export interface LocalReference { readonly value: string; readonly accessorProperty: string; } /* * Parses a [inline|relative] [model|parameter] reference in the swagger spec. * This method does not handle parsing paths "/subscriptions/{subscriptionId}/etc.". * * @param {string} reference Reference to be parsed. * * @return {object} result * {string} [result.filePath] Filepath present in the reference. Examples are: * - '../newtwork.json#/definitions/Resource' => '../network.json' * - '../examples/nic_create.json' => '../examples/nic_create.json' * {object} [result.localReference] Provides information about the local reference in the * json document. * {string} [result.localReference.value] The json reference value. Examples are: * - '../newtwork.json#/definitions/Resource' => '#/definitions/Resource' * - '#/parameters/SubscriptionId' => '#/parameters/SubscriptionId' * {string} [result.localReference.accessorProperty] The json path expression that can be * used by * eval() to access the desired object. Examples are: * - '../newtwork.json#/definitions/Resource' => 'definitions.Resource' * - '#/parameters/SubscriptionId' => 'parameters,SubscriptionId' */ export function parseReferenceInSwagger(reference: string): Reference { if (!reference || (reference && reference.trim().length === 0)) { throw new Error("reference cannot be null or undefined and it must be a non-empty string."); } if (reference.includes("#")) { // local reference in the doc if (reference.startsWith("#/")) { return { localReference: { value: reference, accessorProperty: reference.slice(2).replace("/", "."), }, }; } else { // filePath+localReference const segments = reference.split("#"); return { filePath: segments[0], localReference: { value: "#" + segments[1], accessorProperty: segments[1].slice(1).replace("/", "."), }, }; } } else { // we are assuming that the string is a relative filePath return { filePath: reference }; } } /* * Same as path.join(), however, it converts backward slashes to forward slashes. * This is required because path.join() joins the paths and converts all the * forward slashes to backward slashes if executed on a windows system. This can * be problematic while joining a url. For example: * path.join( * 'https://github.com/Azure/openapi-validation-tools/blob/master/lib', * '../examples/foo.json') * returns * 'https:\\github.com\\Azure\\openapi-validation-tools\\blob\\master\\examples\\foo.json' * instead of * 'https://github.com/Azure/openapi-validation-tools/blob/master/examples/foo.json' * * @param variable number of arguments and all the arguments must be of type string. Similar to * the API provided by path.join() * https://nodejs.org/dist/latest-v6.x/docs/api/path.html#path_path_join_paths * @return {string} resolved path */ export function joinPath(...args: string[]): string { let finalPath = path.join(...args); finalPath = finalPath.replace(/\\/gi, "/"); finalPath = finalPath.replace(/^(http|https):\/(.*)/gi, "$1://$2"); return finalPath; } // If the spec path is a url starting with https://github then let us auto convert it to an // https://raw.githubusercontent url. export function checkAndResolveGithubUrl(inputPath: string): string { if (inputPath.startsWith("https://github")) { return inputPath.replace( /^https:\/\/github\.com\/(.*)\/blob\/(.*)/gi, "https://raw.githubusercontent.com/$1/$2" ); } return inputPath; } /** * Finds the git root directory for the given directory. */ export function findGitRootDirectory(dir: string): string | undefined { while (true) { const gitDir = path.join(dir, ".git"); if (fs.existsSync(gitDir)) { return dir; } const newDIr = path.dirname(dir); if (newDIr === dir) { return undefined; } dir = newDIr; } } /* * Merges source object into the target object * @param {object} source The object that needs to be merged * * @param {object} target The object to be merged into * * @returns {object} target - Returns the merged target object. */ export function mergeObjects<T extends MutableStringMap<Data>>(source: T, target: T): T { const result: MutableStringMap<Data> = target; for (const [key, sourceProperty] of mapEntries(source)) { if (Array.isArray(sourceProperty)) { const targetProperty = target[key]; if (!targetProperty) { result[key] = sourceProperty; } else if (!Array.isArray(targetProperty)) { throw new Error( `Cannot merge ${key} from source object into target object because the same property ` + `in target object is not (of the same type) an Array.` ); } else { result[key] = mergeArrays(sourceProperty, targetProperty); } } else { result[key] = cloneDeep(sourceProperty); } } return result as T; } /* * Merges source array into the target array * @param {array} source The array that needs to be merged * * @param {array} target The array to be merged into * * @returns {array} target - Returns the merged target array. */ export function mergeArrays<T extends Data>(source: readonly T[], target: T[]): T[] { if (!Array.isArray(target) || !Array.isArray(source)) { return target; } source.forEach((item) => { target.push(cloneDeep(item)); }); return target; } /* * Gets the object from the given doc based on the provided json reference pointer. * It returns undefined if the location is not found in the doc. * @param {object} doc The source object. * * @param {string} ptr The json reference pointer * * @returns {unknown} result - Returns the value that the ptr points to, in the doc. */ export function getObject(doc: StringMap<unknown>, ptr: string): unknown { let result: unknown; try { result = jsonPointer.get(doc, ptr); } catch (err) { log.error(`cannot get object from jsonPointer ${ptr}`); log.error(err); throw err; } return result; } /* * Sets the given value at the location provided by the ptr in the given doc. * @param {object} doc The source object. * * @param {string} ptr The json reference pointer. * * @param {unknown} value The value that needs to be set at the * location provided by the ptr in the doc. * @param {overwrite} Optional parameter to decide if a pointer value should be overwritten. */ export function setObject( doc: StringMap<unknown>, ptr: string, value: unknown, overwrite = true ): any { let result; try { if (overwrite || !jsonPointer.has(doc, ptr)) { result = jsonPointer.set(doc, ptr, value); } } catch (err) { log.error(err); } return result; } /** * Gets provider namespace from the given path. In case of multiple, last one will be returned. * @param {string} pathStr The path of the operation. * Example "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/ * providers/{resourceProviderNamespace}/{parentResourcePath}/{resourceType}/ * {resourceName}/providers/Microsoft.Authorization/roleAssignments" * will return "Microsoft.Authorization". * * @returns {string} result - provider namespace from the given path. */ export function getProvider(pathStr?: string | null): string | undefined { if ( pathStr === null || pathStr === undefined || typeof pathStr.valueOf() !== "string" || !pathStr.trim().length ) { throw new Error( "pathStr is a required parameter of type string and it cannot be an empty string." ); } let result; // Loop over the paths to find the last matched provider namespace // eslint-disable-next-line no-constant-condition while (true) { const pathMatch = providerRegEx.exec(pathStr); if (pathMatch === null) { break; } result = pathMatch[1]; } return result; } export interface PathProvider { provider: string; type: "resource-manager" | "data-plane"; } export function getProviderFromSpecPath(specPath: string): PathProvider | undefined { const managementPlaneProviderInSpecPathRegEx: RegExp = /\/resource-manager\/(.*?)\//gi; const dataPlaneProviderInSpecPathRegEx: RegExp = /\/data-plane\/(.*?)\//gi; const manageManagementMatch = managementPlaneProviderInSpecPathRegEx.exec(specPath); const dataPlaneMatch = dataPlaneProviderInSpecPathRegEx.exec(specPath); return manageManagementMatch === null ? dataPlaneMatch === null ? undefined : { provider: dataPlaneMatch[1], type: "data-plane" } : { provider: manageManagementMatch[1], type: "resource-manager" }; } export const getValueByJsonPointer = (obj: any, pointer: string | string[]) => { const refTokens = Array.isArray(pointer) ? pointer : parse(pointer); for (let i = 0; i < refTokens.length; ++i) { const tok = refTokens[i]; if (!(typeof obj === "object" && tok in obj)) { throw new Error("Invalid reference token: " + tok); } obj = obj[tok]; } return obj; }; const jsonPointerUnescape = (str: string) => { return str.replace(/~1/g, "/").replace(/~0/g, "~"); }; const parse = (pointer: string) => { if (pointer === "") { return []; } if (pointer.charAt(0) !== "/") { throw new Error("Invalid JSON pointer: " + pointer); } return pointer.substring(1).split(/\//).map(jsonPointerUnescape); }; export function getProviderFromFilePath(pathStr: string): string | undefined { const resourceProviderPattern: RegExp = /[A-Z][a-z0-9]+(\.([A-Z]{1,5}[a-z0-9]+)+[A-Z]{0,5})+/g; const words = pathStr.split(/\\|\//gi); for (const it of words) { if (resourceProviderPattern.test(it)) { return it; } } return undefined; } const safeLoad = (content: string) => { try { return YAML.load(content) as any; } catch (err) { return undefined; } }; /** * @return return undefined indicates not found, otherwise return non-empty string. */ export const getDefaultReadmeTag = (markDown: commonmark.Node): string | undefined => { const startNode = markDown; const codeBlockMap = amd.getCodeBlocksAndHeadings(startNode); const latestHeader = "Basic Information"; const headerBlock = codeBlockMap[latestHeader]; if (headerBlock && headerBlock.literal) { const latestDefinition = safeLoad(headerBlock.literal); if (latestDefinition && latestDefinition.tag) { return latestDefinition.tag; } } for (const idx of Object.keys(codeBlockMap)) { const block = codeBlockMap[idx]; if ( !block || !block.info || !block.literal || !/^(yaml|json)$/.test(block.info.trim().toLowerCase()) ) { continue; } const latestDefinition = safeLoad(block.literal); if (latestDefinition && latestDefinition.tag) { return latestDefinition.tag; } } return undefined; }; export async function getInputFiles(readMe: string, tag?: string): Promise<string[]> { const result: string[] = []; const readMeStr = await readFile(checkAndResolveGithubUrl(readMe)); const cmd = parseMarkdown(readMeStr); tag = tag ?? getDefaultReadmeTag(cmd.markDown); if (tag) { amd.getInputFilesForTag(cmd.markDown, tag)?.forEach((file) => result.push(file)); } return result; } export async function getDefaultTag(readMe: string): Promise<string | undefined> { const readMeStr = await readFile(checkAndResolveGithubUrl(readMe)); const cmd = parseMarkdown(readMeStr); return getDefaultReadmeTag(cmd.markDown); } export async function getApiScenarioFiles( readMe: string, tag: string, flag?: string ): Promise<string[]> { const readMeStr = await readFile(checkAndResolveGithubUrl(readMe)); const cmd = parseMarkdown(readMeStr); const codeBlockMap = amd.getCodeBlocksAndHeadings(cmd.markDown); const pattern = flag ? `yaml $(tag) == '${tag}' && $(${flag})` : `yaml $(tag) == '${tag}'`; for (const idx of Object.keys(codeBlockMap)) { const block = codeBlockMap[idx]; if (!block || !block.info || !block.literal || !(block.info.trim() === pattern)) { continue; } const latestDefinition = safeLoad(block.literal); if (latestDefinition && latestDefinition["test-resources"]) { return latestDefinition["test-resources"]; } } return []; } export function getApiVersionFromFilePath(filePath: string): string { const apiVersionPattern: RegExp = /^.*\/(stable|preview)+\/([0-9]{4}-[0-9]{2}-[0-9]{2}(-preview)?)\/.*\.(json|yaml)$/i; const apiVersionMatch = apiVersionPattern.exec(filePath); return apiVersionMatch === null ? "" : apiVersionMatch[2]; } const providerRegEx = new RegExp("/providers/(:?[^{/]+)", "gi"); /** * Gets provider namespace from the given path. In case of multiple, last one will be returned. * @param {string} pathStr The path of the operation. * Example "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/ * providers/{resourceProviderNamespace}/{parentResourcePath}/{resourceType}/ * {resourceName}/providers/Microsoft.Authorization/roleAssignments" * will return "Microsoft.Authorization". * * @returns {string} result - provider namespace from the given path. */ export function getProviderFromPathTemplate(pathStr?: string | null): string | undefined { if ( pathStr === null || pathStr === undefined || typeof pathStr.valueOf() !== "string" || !pathStr.trim().length ) { throw new Error( "pathStr is a required parameter of type string and it cannot be an empty string." ); } let result; // Loop over the paths to find the last matched provider namespace // eslint-disable-next-line no-constant-condition while (true) { const pathMatch = providerRegEx.exec(pathStr); if (pathMatch === null) { break; } result = pathMatch[1]; } return result; } /** * Gets provider resource type from the given path. * @param {string} pathStr The path of the operation. * Example "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}/ * providers/{resourceProviderNamespace}/{parentResourcePath}/{resourceType}/ * {resourceName}/providers/Microsoft.Authorization/roleAssignments" * will return "roleAssignments". * * @returns {string} result - provider resource type from the given path. */ export function getResourceType(pathStr: string, provider?: string): string { if (provider !== undefined && provider !== null) { const index = pathStr.indexOf(provider); if (index > 0) { pathStr = pathStr.substring(index + provider.length + 1); } } let resourceType = pathStr; const slashIndex = pathStr.indexOf("/"); if (slashIndex > 0) { resourceType = pathStr.substring(0, slashIndex); } return resourceType; } /** * Gets last child resouce url to match. * @param {string} requestUrl The request url. * Example "/subscriptions/randomSub/resourceGroups/randomRG/providers/providers/Microsoft.Storage/ * storageAccounts/storageoy6qv/blobServices/default/containers/ * privatecontainer/providers/Microsoft.Authorization/roleAssignments/3fa73e4b-d60d-43b2-a248-fb776fd0bf60" * will return "roleAssignments". * * @returns {string} last child resource url. */ export function getLastResourceUrlToMatch(requestUrl: string): string { let index = requestUrl.lastIndexOf("/providers"); if (index > 0) { const originUrlWithoutLastChildResource = requestUrl.substring(0, index); index = originUrlWithoutLastChildResource.lastIndexOf("/"); if (index > 0) { requestUrl = requestUrl.substring(index); } } return requestUrl; } /** /* * Clones a github repository in the given directory. * @param {string} directory to where to clone the repository. * * @param {string} url of the repository to be cloned. * Example "https://github.com/Azure/azure-rest-api-specs.git" or * "git@github.com:Azure/azure-rest-api-specs.git". * * @param {string} [branch] to be cloned instead of the default branch. */ export function gitClone(directory: string, url: string, branch: string | undefined): void { if ( url === null || url === undefined || typeof url.valueOf() !== "string" || !url.trim().length ) { throw new Error("url is a required parameter of type string and it cannot be an empty string."); } if ( directory === null || directory === undefined || typeof directory.valueOf() !== "string" || !directory.trim().length ) { throw new Error( "directory is a required parameter of type string and it cannot be an empty string." ); } // If the directory exists then we assume that the repo to be cloned is already present. if (fs.existsSync(directory)) { if (fs.lstatSync(directory).isDirectory()) { try { removeDirSync(directory); } catch (err) { const text = util.inspect(err, { depth: null }); throw new Error(`An error occurred while deleting directory ${directory}: ${text}.`); } } else { try { fs.unlinkSync(directory); } catch (err) { const text = util.inspect(err, { depth: null }); throw new Error(`An error occurred while deleting file ${directory}: ${text}.`); } } } try { fs.mkdirSync(directory); } catch (err) { const text = util.inspect(err, { depth: null }); throw new Error(`An error occurred while creating directory ${directory}: ${text}.`); } try { const isBranchDefined = branch !== null && branch !== undefined && typeof branch.valueOf() === "string"; const cmd = isBranchDefined ? `git clone --depth=1 --branch ${branch} ${url} ${directory}` : `git clone --depth=1 ${url} ${directory}`; execSync(cmd, { encoding: "utf8" }); } catch (err) { throw new Error( `An error occurred while cloning git repository: ${util.inspect(err, { depth: null, })}.` ); } } /* * Removes given directory recursively. * @param {string} dir directory to be deleted. */ export function removeDirSync(dir: string): void { if (fs.existsSync(dir)) { fs.readdirSync(dir).forEach((file) => { const current = dir + "/" + file; if (fs.statSync(current).isDirectory()) { removeDirSync(current); } else { fs.unlinkSync(current); } }); fs.rmdirSync(dir); } } /* * Finds the first content-type that contains "/json". Only supported Content-Types are * "text/json" & "application/json" so we perform first best match that contains '/json' * * @param {array} consumesOrProduces Array of content-types. * @returns {string} firstMatchedJson content-type that contains "/json". */ export function getJsonContentType(consumesOrProduces: string[]): string | undefined { return consumesOrProduces ? consumesOrProduces.find((contentType) => contentType.match(/.*\/json.*/gi) !== null) : undefined; } /** * Determines whether the given string is url encoded * @param {string} str - The input string to be verified. * @returns {boolean} result - true if str is url encoded; false otherwise. */ export function isUrlEncoded(str: string): boolean { str = str || ""; try { return str !== decodeURIComponent(str); } catch (e) { return false; } } export function kvPairsToObject(entries: any) { const result: any = {}; for (const [key, value] of entries) { // each 'entry' is a [key, value] tupple result[key] = value; } return result; } /** * Sanitizes the file name by replacing special characters with * empty string and by replacing space(s) with _. * @param {string} str - The string to be sanitized. * @returns {string} result - The sanitized string. */ export const sanitizeFileName = (str: string): string => // eslint-disable-next-line no-useless-escape str ? str.replace(/[{}[\]'";(\)#@~`!%&\^\$\+=,\/\\?<>\|\*:]/gi, "").replace(/(\s+)/gi, "_") : str; /** * Contains the reverse mapping of http.STATUS_CODES */ export const statusCodeStringToStatusCode = lodash.invert( lodash.mapValues(http.STATUS_CODES, (value: string) => value.replace(/ |-/g, "").toLowerCase()) ); export type Writable<T> = { -readonly [P in keyof T]: T[P] }; export const waitUntilLowLoad = async () => { let lastTime = Date.now(); let waterMark = 0; const startTime = lastTime; // eslint-disable-next-line no-constant-condition while (true) { await new Promise((resolve) => setTimeout(resolve, 0)); const now = Date.now(); // If event loop lag is less than 2ms then assume we are under low load if (now - startTime > 60000) { return; } if (now - lastTime <= 5) { ++waterMark; if (waterMark > 1) { return; } } else { waterMark = 0; } lastTime = now; } }; export const shuffleArray = (a: any[]) => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }; export const usePseudoRandom = { seed: Math.floor(Math.random() * 10000000000), }; /** * Generates a psudorandom number with seed */ function* mulberry32(seed: number) { let t = (seed += 0x6d2b79f5); while (true) { t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); yield (t ^ ((t >>> 14) >>> 0)) / 4294967296; } } let generator: any = undefined; export const resetPseudoRandomSeed = (seed?: number) => { usePseudoRandom.seed = seed ?? Math.floor(Math.random() * 10000000000); generator = undefined; }; export const getRandomString = (length?: number) => { if (generator === undefined) { generator = mulberry32(usePseudoRandom.seed); } return generator .next() .value.toString(36) .slice(0 - (length ?? 6)); }; export const findPathsToKey = (options: { key: string; obj: any; pathToKey?: string; }): string[] => { const results = []; (function findKey({ key, obj, pathToKey }) { const oldPath = `${pathToKey ? pathToKey : ""}`; if (obj && obj.hasOwnProperty(key)) { results.push(`${oldPath}.${key}`); return; } if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { for (const k in obj) { if (obj.hasOwnProperty(k)) { if (Array.isArray(obj[k])) { for (let j = 0; j < obj[k].length; j++) { findKey({ obj: obj[k][j], key, pathToKey: `${oldPath}${k}['${j}']`, }); } } if (obj[k] !== null && typeof obj[k] === "object") { findKey({ obj: obj[k], key, pathToKey: /[\*|\{|\[|\}|\}|\,|\.]/.test(k) ? `${oldPath}['${k}']` : `${oldPath}.${k}`, }); } } } } })(options); return results; }; export const findPathToValue = (arr: string[], obj: any, value: string) => { return arr.reduce((pre: string[], cur: string) => { lodash.get(obj, cur.substr(1)) === value && pre.push(cur); return pre; }, []); };