UNPKG

16.3 kBJavaScriptView Raw
1import chalk from "chalk";
2import {configObject} from "./config.js";
3import {cached} from "./decorators.js";
4import rp from "request-promise";
5
6global.chalk = chalk;
7global.log = (...text) => console.log(...text);
8global.write = (...text) => process.stdout.write(...text);
9global.elog = (...text) => console.log(...text);
10global.ewrite = (...text) => process.stderr.write(...text);
11global.errorLog = (...text) => log(...text.map(chalk.red));
12
13export class lib{
14 //This function takes 2 required arguemnts:
15 // env: the enviornment you wish to use
16 // and either:
17 // 'path', the short path to the resource. ex '/presets/'
18 // 'path_full', the full path to the resource like 'https://discovery-dev.sdvi.com/presets'
19 //
20 // If the method is anything but GET, either payload or body should be set.
21 // payload should be a javascript object to be turned into json as the request body
22 // body should be a string that is passed as the body. for example: the python code of a preset.
23 //
24 // qs are the querystring parameters, in a key: value object.
25 // {filter: "name=test name"} becomes something like 'filter=name=test+name'
26 //
27 // headers are the headers of the request. "Content-Type" is already set if
28 // payload is given as a parameter
29 //
30 // fullResponse should be true if you want to receive the request object,
31 // not just the returned data.
32 static async makeAPIRequest({
33 env, path, path_full, fullPath,
34 payload, body, method = "GET",
35 qs, headers = {},
36 fullResponse = false, timeout = configObject.timeout || 20000
37 }){
38 //backwards compatability from ruby script
39 if(fullPath) path_full = fullPath;
40 //Keys are defined in enviornment variables
41 let config = configObject?.api?.[env];
42 if(!config) {
43 throw new UnconfiguredEnvError(env);
44 };
45 //Protect PROD and UAT(?) if the --no-protect flag was not set.
46 if(method !== "GET" && !configObject.dangerModify){
47 if(env === "UAT" && configObject.restrictUAT || env === "PROD"){
48 throw new ProtectedEnvError(env);
49 }
50 }
51
52 let rally_api_key = config.key;
53 let rally_api = config.url;
54 if(path && path.startsWith("/v1.0/")){
55 rally_api = rally_api.replace("/api/v2", "/api");
56 }
57
58 path = path_full || rally_api + path;
59 if(payload){
60 body = JSON.stringify(payload, null, 4);
61 }
62
63 if(payload){
64 headers["Content-Type"] = "application/vnd.api+json";
65 }
66
67 let fullHeaders = {
68 //SDVI ignores this header sometimes.
69 Accept: "application/vnd.api+json",
70 "X-SDVI-Client-Application": "Discovery-rtlib-" + (configObject.appName || "commandline"),
71 ...headers,
72 }
73
74 if(configObject.vvverbose){
75 log(`${method} @ ${path}`)
76 log(JSON.stringify(fullHeaders, null, 4))
77
78 if(body){
79 log(body);
80 }else{
81 log("(No body")
82 }
83 }
84
85 let requestOptions = {
86 method, body, qs, uri: path,
87 timeout,
88 auth: {bearer: rally_api_key},
89 headers: fullHeaders,
90 simple: false, resolveWithFullResponse: true,
91 };
92
93 let response;
94 try{
95 response = await rp(requestOptions);
96 if(configObject.vverbose || configObject.vvverbose){
97 log(chalk`${method} @ ${response.request.uri.href}`);
98 }
99 }catch(e){
100 if(e?.cause.code === "ESOCKETTIMEDOUT"){
101 throw new APIError(response || {}, requestOptions, body);
102 }else{
103 throw e;
104 }
105 }
106
107 //Throw an error for any 5xx or 4xx
108 if(!fullResponse && ![200, 201, 202, 203, 204].includes(response.statusCode)){
109 throw new APIError(response, requestOptions, body);
110 }
111 let contentType = response.headers["content-type"];
112 let isJSONResponse = contentType === "application/vnd.api+json" || contentType === "application/json";
113
114 if(configObject.vvverbose){
115 log(response.body);
116 }
117
118 if(fullResponse){
119 return response;
120 }else if(isJSONResponse){
121 if([200, 201, 202, 203, 204].includes(response.statusCode) && !response?.body?.trim()) return {};
122 try{
123 return JSON.parse(response.body);
124 }catch(e){
125 log(response.body);
126 throw new AbortError("Body is not valid json: ");
127 }
128 }else{
129 return response.body;
130 }
131 }
132
133 //Index a json endpoint that returns a {links} field.
134 //This function returns the merged data objects as an array
135 //
136 //Additonal options (besides makeAPIRequest options):
137 // - Observe: function to be called for each set of data from the api
138 static async indexPath(env, path){
139 let all = [];
140
141 let opts = typeof env === "string" ? {env, path} : env;
142 let json = await this.makeAPIRequest(opts);
143
144 let [numPages, pageSize] = this.numPages(json.links.last);
145 //log(`num pages: ${numPages} * ${pageSize}`);
146
147 all = [...json.data];
148 while(json.links.next){
149 json = await this.makeAPIRequest({...opts, path_full: json.links.next});
150 if(opts.observe) await opts.observe(json.data);
151 all = [...all, ...json.data];
152 }
153
154 return all;
155 }
156
157 //Returns number of pages and pagination size
158 static numPages(str){
159 return /page=(\d+)p(\d+)/.exec(str).slice(1);
160 }
161
162 static arrayChunk(array, chunkSize){
163 let newArr = [];
164 for (let i = 0; i < array.length; i += chunkSize){
165 newArr.push(array.slice(i, i + chunkSize));
166 }
167 return newArr;
168 }
169
170 static async doPromises(promises, result = [], cb){
171 for(let promise of promises){
172 let res = await promise;
173 result.push(res);
174 if(cb){
175 cb(res.data);
176 }
177 }
178 return result
179 }
180
181 static clearProgress(size = 30){
182 if(!configObject.globalProgress) return;
183 process.stderr.write(`\r${" ".repeat(size + 15)}\r`);
184 }
185
186 static async drawProgress(i, max, size = process.stdout.columns - 15 || 15){
187 if(!configObject.globalProgress) return;
188 if(size > 45) size = 45;
189 let pct = Number(i) / Number(max);
190 //clamp between 0 and 1
191 pct = pct < 0 ? 0 : pct > 1 ? 1 : pct;
192 let numFilled = Math.floor(pct * size);
193 let numEmpty = size - numFilled;
194
195 this.clearProgress(size);
196 process.stderr.write(`[${"*".repeat(numFilled)}${" ".repeat(numEmpty)}] ${i} / ${max}`);
197 }
198
199
200 static async keepalive(func, inputData, {chunksize = 20, observe = async _=>_, progress = configObject.globalProgress} = {}){
201 let total = inputData ? inputData.length : func.length;
202 let i = 0;
203 let createPromise = () => {
204 let ret;
205 if(i >= total) return [];
206 if(inputData){
207 ret = [i, func(inputData[i])];
208 }else{
209 ret = [i, func[i]()];
210 }
211
212 i++;
213 return ret;
214 }
215
216 let values = [];
217 let finished = 0;
218 if(progress) process.stderr.write("\n");
219 let threads = [...this.range(chunksize)].map(async whichThread => {
220 while(true){
221 let [i, currentPromise] = createPromise();
222 if(i == undefined) break;
223 values[i] = await observe(await currentPromise);
224 if(progress) this.drawProgress(++finished, total);
225 }
226 });
227 await Promise.all(threads);
228 if(progress) process.stderr.write("\n");
229
230
231 return values;
232 }
233
234 static *range(start, end){
235 if(end === undefined){
236 end = start;
237 start = 0;
238 }
239 while(start < end) yield start++;
240 }
241
242 //Index a json endpoint that returns a {links} field.
243 //
244 //This function is faster than indexPath because it can guess the pages it
245 //needs to retreive so that it can request all assets at once.
246 //
247 //This function assumes that the content from the inital request is the
248 //first page, so starting on another page may cause issues. Consider
249 //indexPath for that.
250 //
251 //Additional opts, besides default indexPath opts:
252 // - chunksize[10]: How often to break apart concurrent requests
253 static async indexPathFast(env, path){
254 let opts = typeof env === "string" ? {env, path} : env;
255 //Create a copy of the options in case we need to have a special first request
256 let start = opts.start || 1;
257 let initOpts = {...opts};
258 if(opts.pageSize){
259 initOpts.qs = {...opts.qs};
260 initOpts.qs.page = `${start}p${opts.pageSize}`;
261 }
262
263 let json = await this.makeAPIRequest(initOpts);
264
265 if(opts.observe && opts.start !== 1) json = await opts.observe(json);
266
267 let baselink = json.links.first;
268 const linkToPage = page => baselink.replace(`page=1p`, `page=${page}p`);
269
270 let [numPages, pageSize] = this.numPages(json.links.last);
271
272 //Construct an array of all the requests that are done simultanously.
273 //Assume that the content from the inital request is the first page.
274 let allResults = await this.keepalive(
275 this.makeAPIRequest,
276 [...this.range(start+1, Number(numPages) + 1 || opts.limit + 1)]
277 .map(i => ({...opts, path_full: linkToPage(i)})),
278 {chunksize: opts.chunksize, observe: opts.observe},
279 );
280 if(start == 1){
281 allResults.unshift(json);
282 }
283 this.clearProgress();
284
285 let all = [];
286 for(let result of allResults){
287 for(let item of result.data){
288 all.push(item);
289 }
290 }
291
292 return all;
293 }
294 static isLocalEnv(env){
295 return !env || env === "LOCAL" || env === "LOC";
296 }
297 static envName(env){
298 if(this.isLocalEnv(env)) return "LOCAL";
299 return env;
300 }
301};
302
303export class AbortError extends Error{
304 constructor(message){
305 super(message);
306 Error.captureStackTrace(this, this.constructor);
307 this.name = "AbortError";
308 }
309}
310
311export class APIError extends Error{
312 constructor(response, opts, body){
313 super(chalk`
314{reset Request returned} {yellow ${response?.statusCode}}{
315{green ${JSON.stringify(opts, null, 4)}}
316{green ${body}}
317{reset ${response.body}}
318===============================
319{red ${response.body ? "Request timed out" : "Bad response from API"}}
320===============================
321 `);
322 this.response = response;
323 this.opts = opts;
324 this.body = body;
325
326 Error.captureStackTrace(this, this.constructor);
327 this.name = "ApiError";
328 }
329}
330
331export class UnconfiguredEnvError extends AbortError{
332 constructor(env){
333 super("Unconfigured enviornment: " + env);
334 this.name = "Unconfigured Env Error";
335 }
336}
337
338export class ProtectedEnvError extends AbortError{
339 constructor(env){
340 super("Protected enviornment: " + env);
341 this.name = "Protected Env Error";
342 }
343}
344
345export class FileTooLargeError extends Error{
346 constructor(file){
347 super(`File ${file.parentAsset ? file.parentAsset.name : "(unknown)"}/${file.name} size is: ${file.sizeGB}g (> ~.2G)`);
348 this.name = "File too large error";
349 }
350}
351
352export class Collection{
353 constructor(arr){
354 this.arr = arr;
355 }
356 [Symbol.iterator](){
357 return this.arr[Symbol.iterator]();
358 }
359 findById(id){
360 return this.arr.find(x => x.id == id);
361 }
362 findByName(name){
363 return this.arr.find(x => x.name == name);
364 }
365 findByNameContains(name){
366 return this.arr.find(x => x.name.includes(name));
367 }
368 log(){
369 for(let d of this){
370 if(d){
371 log(d.chalkPrint(true));
372 }else{
373 log(chalk`{red (None)}`);
374 }
375 }
376 }
377 get length(){return this.arr.length;}
378}
379
380export class RallyBase{
381 static handleCaching(){
382 if(!this.cache) this.cache = [];
383 }
384 static isLoaded(env){
385 if(!this.hasLoadedAll) return;
386 return this.hasLoadedAll[env];
387 }
388 static async getById(env, id, qs){
389 this.handleCaching();
390 for(let item of this.cache){
391 if(item.id == id && item.remote === env || `${env}-${id}` === item.metastring) return item;
392 }
393
394 let data = await lib.makeAPIRequest({
395 env, path: `/${this.endpoint}/${id}`,
396 qs
397 });
398 if(data.data){
399 let o = new this({data: data.data, remote: env, included: data.included});
400 this.cache.push(o);
401 return o;
402 }
403 }
404
405 static async getByName(env, name, qs){
406 this.handleCaching();
407 for(let item of this.cache){
408 if(item.name === name && item.remote === env) return item;
409 }
410
411 let data = await lib.makeAPIRequest({
412 env, path: `/${this.endpoint}`,
413 qs: {...qs, filter: `name=${name}` + (qs ? qs.filter : "")},
414 });
415 //TODO included might not wokr correctly here
416 if(data.data[0]){
417 let o = new this({data: data.data[0], remote: env, included: data.included});
418 this.cache.push(o);
419 return o;
420 }
421 }
422
423 static async getAllPreCollect(d){return d;}
424 static async getAll(env){
425 this.handleCaching();
426 let datas = await lib.indexPathFast({
427 env, path: `/${this.endpoint}`,
428 pageSize: "50",
429 qs: {sort: "id"},
430 });
431 datas = await this.getAllPreCollect(datas);
432 let all = new Collection(datas.map(data => new this({data, remote: env})));
433 this.cache = [...this.cache, ...all.arr];
434 return all;
435 }
436 static async removeCache(env){
437 this.handleCaching();
438 this.cache = this.cache.filter(x => x.remote !== env);
439 }
440
441 //Specific turns name into id based on env
442 //Generic turns ids into names
443 async resolveApply(type, dataObj, direction){
444 let obj;
445 if(direction == "generic"){
446 obj = await type.getById(this.remote, dataObj.id);
447 if(obj){
448 dataObj.name = obj.name
449 }
450 }else if(direction == "specific"){
451 obj = await type.getByName(this.remote, dataObj.name);
452 if(obj){
453 dataObj.id = obj.id
454 }
455 }
456 return obj;
457 }
458
459 //Type is the baseclass you are looking for (should extend RallyBase)
460 //name is the name of the field
461 //isArray is true if it has multiple cardinailty, false if it is single
462 //direction gets passed directly to resolveApply
463 async resolveField(type, name, isArray=false, direction="generic"){
464 // ignore empty fields
465 let field = this.relationships[name];
466 if(!field?.data) return;
467
468 if(isArray){
469 return await Promise.all(field.data.map(o => this.resolveApply(type, o, direction)));
470 }else{
471 return await this.resolveApply(type, field.data, direction);
472 }
473 }
474
475 cleanup(){
476 for(let [key, val] of Object.entries(this.relationships)){
477 //Remove ids from data
478 if(val.data){
479 if(val.data.id){
480 delete val.data.id;
481 }else if(val.data[0]){
482 for(let x of val.data) delete x.id;
483 }
484 }
485 delete val.links;
486 }
487 // organization is unused (?)
488 delete this.relationships.organization;
489 // id is specific to envs
490 // but save source inside meta string in case we need it
491 this.metastring = this.remote + "-" + this.data.id;
492 delete this.data.id;
493 // links too
494 delete this.data.links;
495 }
496}
497
498export function sleep(time = 1000){
499 return new Promise(resolve => setTimeout(resolve, time));
500}