UNPKG

rally-tools

Version:
541 lines (471 loc) 17.3 kB
import chalk from "chalk"; import {configObject} from "./config.js"; import {cached} from "./decorators.js"; import rp from "request-promise"; global.chalk = chalk; global.log = (...text) => console.log(...text); global.write = (...text) => process.stdout.write(...text); global.elog = (...text) => console.error(...text); global.ewrite = (...text) => process.stderr.write(...text); global.errorLog = (...text) => log(...text.map(chalk.red)); export class lib{ //This function takes 2 required arguemnts: // env: the enviornment you wish to use // and either: // 'path', the short path to the resource. ex '/presets/' // 'path_full', the full path to the resource like 'https://discovery-dev.sdvi.com/presets' // // If the method is anything but GET, either payload or body should be set. // payload should be a javascript object to be turned into json as the request body // body should be a string that is passed as the body. for example: the python code of a preset. // // qs are the querystring parameters, in a key: value object. // {filter: "name=test name"} becomes something like 'filter=name=test+name' // // headers are the headers of the request. "Content-Type" is already set if // payload is given as a parameter // // fullResponse should be true if you want to receive the request object, // not just the returned data. static async makeAPIRequest({ env, path, path_full, fullPath, payload, body, method = "GET", qs, headers = {}, fullResponse = false, timeout = configObject.timeout || 20000 }){ //backwards compatability from ruby script if(fullPath) path_full = fullPath; //Keys are defined in enviornment variables let config = configObject?.api?.[env]; if(!config) { throw new UnconfiguredEnvError(env); }; //Protect PROD and UAT(?) if the --no-protect flag was not set. if(method !== "GET" && !configObject.dangerModify){ if(env === "UAT" && configObject.restrictUAT || env === "PROD"){ throw new ProtectedEnvError(env); } } let rally_api_key = config.key; let rally_api = config.url; if(path && path.startsWith("/v1.0/")){ rally_api = rally_api.replace("/api/v2", "/api"); } path = path_full || rally_api + path; if(payload){ body = JSON.stringify(payload, null, 4); } if(payload){ headers["Content-Type"] = "application/vnd.api+json"; } let fullHeaders = { //SDVI ignores this header sometimes. Accept: "application/vnd.api+json", "X-SDVI-Client-Application": "Discovery-rtlib-" + (configObject.appName || "commandline"), ...headers, } if(configObject.vvverbose){ log(`${method} @ ${path}`) log(JSON.stringify(fullHeaders, null, 4)) if(body){ log(body); }else{ log("(No body") } } let requestOptions = { method, body, qs, uri: path, timeout, auth: {bearer: rally_api_key}, headers: fullHeaders, simple: false, resolveWithFullResponse: true, }; let response; try{ response = await rp(requestOptions); if(configObject.vverbose || configObject.vvverbose){ log(chalk`${method} @ ${response.request.uri.href}`); } }catch(e){ if(e?.cause.code === "ESOCKETTIMEDOUT"){ throw new APIError(response || {}, requestOptions, body); }else{ throw e; } } //Throw an error for any 5xx or 4xx if(!fullResponse && ![200, 201, 202, 203, 204].includes(response.statusCode)){ throw new APIError(response, requestOptions, body); } let contentType = response.headers["content-type"]; let isJSONResponse = contentType === "application/vnd.api+json" || contentType === "application/json"; if(configObject.vvverbose){ log(response.body); } if(fullResponse){ return response; }else if(isJSONResponse){ if([200, 201, 202, 203, 204].includes(response.statusCode) && !response?.body?.trim()) return {}; try{ return JSON.parse(response.body); }catch(e){ log(response.body); throw new AbortError("Body is not valid json: "); } }else{ return response.body; } } //Index a json endpoint that returns a {links} field. //This function returns the merged data objects as an array // //Additonal options (besides makeAPIRequest options): // - Observe: function to be called for each set of data from the api static async indexPath(env, path){ let opts = typeof env === "string" ? {env, path} : env; opts.maxParallelRequests = 1; let index = new IndexObject(opts); return await index.fullResults(); } static clearProgress(size = 30){ if(!configObject.globalProgress) return; process.stderr.write(`\r${" ".repeat(size + 15)}\r`); } static async drawProgress(i, max, size = process.stdout.columns - 15 || 15){ if(!configObject.globalProgress) return; if(size > 45) size = 45; let pct = Number(i) / Number(max); //clamp between 0 and 1 pct = pct < 0 ? 0 : pct > 1 ? 1 : pct; let numFilled = Math.floor(pct * size); let numEmpty = size - numFilled; this.clearProgress(size); process.stderr.write(`[${"*".repeat(numFilled)}${" ".repeat(numEmpty)}] ${i} / ${max}`); } static async keepalive(funcs){ for(let f of funcs){ await f(); } } //Index a json endpoint that returns a {links} field. // //This function is faster than indexPath because it can guess the pages it //needs to retreive so that it can request all assets at once. // //This function assumes that the content from the inital request is the //first page, so starting on another page may cause issues. Consider //indexPath for that. // //Additional opts, besides default indexPath opts: // - chunksize[10]: How often to break apart concurrent requests static async indexPathFast(env, path){ let opts = typeof env === "string" ? {env, path} : env; let index = new IndexObject(opts); return await index.fullResults(); } static isLocalEnv(env){ return !env || env === "LOCAL" || env === "LOC"; } static envName(env){ if(this.isLocalEnv(env)) return "LOCAL"; return env; } }; export class AbortError extends Error{ constructor(message){ super(message); Error.captureStackTrace(this, this.constructor); this.name = "AbortError"; } } export class APIError extends Error{ constructor(response, opts, body){ super(chalk` {reset Request returned} {yellow ${response?.statusCode}}{ {green ${JSON.stringify(opts, null, 4)}} {green ${body}} {reset ${response.body}} =============================== {red ${response.body ? "Request timed out" : "Bad response from API"}} =============================== `); this.response = response; this.opts = opts; this.body = body; Error.captureStackTrace(this, this.constructor); this.name = "ApiError"; } } export class UnconfiguredEnvError extends AbortError{ constructor(env){ super("Unconfigured enviornment: " + env); this.name = "Unconfigured Env Error"; } } export class ProtectedEnvError extends AbortError{ constructor(env){ super("Protected enviornment: " + env); this.name = "Protected Env Error"; } } export class FileTooLargeError extends Error{ constructor(file){ super(`File ${file.parentAsset ? file.parentAsset.name : "(unknown)"}/${file.name} size is: ${file.sizeGB}g (> ~.2G)`); this.name = "File too large error"; } } export class Collection{ constructor(arr){ this.arr = arr; } [Symbol.iterator](){ return this.arr[Symbol.iterator](); } findById(id){ return this.arr.find(x => x.id == id); } findByName(name){ return this.arr.find(x => x.name == name); } findByNameContains(name){ return this.arr.find(x => x.name.includes(name)); } log(){ for(let d of this){ if(d){ log(d.chalkPrint(true)); }else{ log(chalk`{red (None)}`); } } } get length(){return this.arr.length;} } export class RallyBase{ static handleCaching(){ if(!this.cache) this.cache = []; } static isLoaded(env){ if(!this.hasLoadedAll) return; return this.hasLoadedAll[env]; } static async getById(env, id, qs){ this.handleCaching(); for(let item of this.cache){ if(item.id == id && item.remote === env || `${env}-${id}` === item.metastring) return item; } let data = await lib.makeAPIRequest({ env, path: `/${this.endpoint}/${id}`, qs }); if(data.data){ let o = new this({data: data.data, remote: env, included: data.included}); this.cache.push(o); return o; } } static async getByName(env, name, qs){ this.handleCaching(); for(let item of this.cache){ if(item.name === name && item.remote === env) return item; } let data = await lib.makeAPIRequest({ env, path: `/${this.endpoint}`, qs: {...qs, filter: `name=${name}` + (qs ? qs.filter : "")}, }); //TODO included might not wokr correctly here if(data.data[0]){ let o = new this({data: data.data[0], remote: env, included: data.included}); this.cache.push(o); return o; } } static async getAllPreCollect(d){return d;} static async getAll(env){ this.handleCaching(); let datas = await lib.indexPathFast({ env, path: `/${this.endpoint}`, pageSize: "50", qs: {sort: "id"}, }); datas = await this.getAllPreCollect(datas); let all = new Collection(datas.map(data => new this({data, remote: env}))); this.cache = [...this.cache, ...all.arr]; return all; } static async removeCache(env){ this.handleCaching(); this.cache = this.cache.filter(x => x.remote !== env); } //Specific turns name into id based on env //Generic turns ids into names async resolveApply(type, dataObj, direction){ let obj; if(direction == "generic"){ obj = await type.getById(this.remote, dataObj.id); if(obj){ dataObj.name = obj.name } }else if(direction == "specific"){ obj = await type.getByName(this.remote, dataObj.name); if(obj){ dataObj.id = obj.id } } return obj; } //Type is the baseclass you are looking for (should extend RallyBase) //name is the name of the field //isArray is true if it has multiple cardinailty, false if it is single //direction gets passed directly to resolveApply async resolveField(type, name, isArray=false, direction="generic"){ // ignore empty fields let field = this.relationships[name]; if(!field?.data) return; if(isArray){ return await Promise.all(field.data.map(o => this.resolveApply(type, o, direction))); }else{ return await this.resolveApply(type, field.data, direction); } } cleanup(){ for(let [key, val] of Object.entries(this.relationships)){ //Remove ids from data if(val.data){ if(val.data.id){ delete val.data.id; }else if(val.data[0]){ for(let x of val.data) delete x.id; } } delete val.links; } // organization is unused (?) delete this.relationships.organization; // id is specific to envs // but save source inside meta string in case we need it this.metastring = this.remote + "-" + this.data.id; delete this.data.id; // links too delete this.data.links; } } export function sleep(time = 1000){ return new Promise(resolve => setTimeout(resolve, time)); } export function* zip(...items){ let iters = items.map(x => x[Symbol.iterator]()); for(;;){ let r = []; for(let i of iters){ let next = i.next() if(next.done) return; r.push(next.value); } yield r; } } export async function* unordered(proms){ let encapsulatedPromises = proms.map(async (x, i) => [i, await x]); while(encapsulatedPromises.length > 0){ let [ind, result] = await Promise.race(encapsulatedPromises.filter(x => x)); yield result; encapsulatedPromises[ind] = undefined; } } export function* range(start, end){ if(end === undefined){ end = start; start = 0; } while(start < end) yield start++; } export class IndexObject { //normal opts from any makeAPIRequest //Note that full_response and pages won't work. // //if you want to start from another page, use `opts.start` //opts.observe: async function(jsonData) => jsonData. Transform the data from the api //opts.maxParallelRequests: number of max api requests to do at once //opts.noCollect: return [] instead of the full data constructor(opts){ this.opts = opts; } linkToPage(page) { return this.baselink.replace(`page=1p`, `page=${page}p`); } async initializeFirstRequest(){ //Create a copy of the options in case we need to have a special first request this.start = this.opts.start || 1; let initOpts = {...this.opts}; if(this.opts.pageSize){ initOpts.qs = {...this.opts.qs}; initOpts.qs.page = `${this.start}p${this.opts.pageSize}`; } this.allResults = []; //we make 1 non-parallel request to the first page so we know how to //format the next requests let json = await lib.makeAPIRequest(initOpts); if(this.opts.observe) json = await this.opts.observe(json); if(!this.opts.noCollect) this.allResults.push(json); this.baselink = json.links.first; this.currentPageRequest = this.start; this.hasHit404 = false; } getNextRequestLink(){ this.currentPageRequest++; return [this.currentPageRequest, this.linkToPage(this.currentPageRequest)]; } ///promiseID is the id in `currentPromises`, so that it can be marked as ///done inside the promise array. promiseID is a number from 0 to ///maxparallel-1 async getNextRequestPromise(promiseID){ let [page, path_full] = this.getNextRequestLink(); return [promiseID, page, await lib.makeAPIRequest({ ...this.opts, path_full, fullResponse: true, })]; } cancel(){ this.willCancel = true; } async fullResults(){ await this.initializeFirstRequest(); let maxParallelRequests = this.opts.maxParallelRequests || this.opts.chunksize || 20; let currentPromises = []; //generate the first set of requests. Everything after this will re-use these i promiseIDs for(let i = 0; i < maxParallelRequests; i++){ currentPromises.push(this.getNextRequestPromise(currentPromises.length)); } for(;;){ let [promiseID, page, requestResult] = await Promise.race(currentPromises.filter(x => x)); if(this.willCancel){ return null; } if(requestResult.statusCode === 404){ this.hasHit404 = true; }else if(requestResult.statusCode === 200){ let json = JSON.parse(requestResult.body); if(this.opts.observe) json = await this.opts.observe(json); if(!this.opts.noCollect) this.allResults.push(json); if(json.data.length === 0) this.hasHit404 = true; }else{ throw new APIError(requestResult, `(unknown args) page ${page}`, null); } if(this.hasHit404){ currentPromises[promiseID] = null; }else{ currentPromises[promiseID] = this.getNextRequestPromise(promiseID); } if(currentPromises.filter(x => x).length === 0) break; } let all = []; for(let result of this.allResults){ for(let item of result.data){ all.push(item); } } return all; } }