UNPKG

ibm-rally-node

Version:

Customized Rally REST Toolkit for Node.js

554 lines (530 loc) 23.3 kB
/** @module RestApi This module presents a higher-level API for interacting with resources in the Rally REST API. */ import _ from 'lodash'; import Request from './request.js'; import callbackify from './util/callbackify.js'; import refUtils from './util/ref.js'; import pkgInfo from '../package.json' with { type: 'json' }; const defaultServer = 'https://rally1.rallydev.com'; const defaultApiVersion = 'v2.0'; function optionsToRequestOptions(options) { const qs = {}; if (options.scope) { if (options.scope.project) { qs.project = refUtils.getRelative(options.scope.project); if (options.scope.hasOwnProperty('up')) { qs.projectScopeUp = options.scope.up; } if (options.scope.hasOwnProperty('down')) { qs.projectScopeDown = options.scope.down; } } else if (options.scope.workspace) { qs.workspace = refUtils.getRelative(options.scope.workspace); } } if (_.isArray(options.fetch)) { qs.fetch = options.fetch.join(','); } else if (_.isString(options.fetch)) { qs.fetch = options.fetch; } return { qs: qs }; } function collectionPost(options, operation, callback) { const relativeRef = refUtils.getRelative(options.ref); if (!relativeRef) { const error = new Error(`Invalid ref: ${options.ref}`); error.errors = [`Invalid ref: ${options.ref}`]; return Promise.reject(error); } return this.request.post(_.merge({ url: `${relativeRef}/${options.collection}/${operation}`, json: { CollectionItems: options.data } }, options.requestOptions, optionsToRequestOptions(options)), callback); } /** The Rally REST API client @constructor @param {object} options (optional) - optional config for the REST client - @member {string} server - server for the Rally API (default: https://rally1.rallydev.com) - @member {string} apiVersion - the Rally REST API version to use for requests (default: v2.0) - @member {string} userName||user - the username to use for requests (default: RALLY_USERNAME env variable) (@deprecated in favor of apiKey) - @member {string} password||pass - the password to use for requests (default: RALLY_PASSWORD env variable) (@deprecated in favor of apiKey) - @member {string} apiKey - the api key to use for requests (default: RALLY_API_KEY env variable) - @member {object} requestOptions - default options for the request: https://github.com/mikeal/request */ export default class RestApi { constructor(options) { options = _.merge({ server: defaultServer, apiVersion: defaultApiVersion, requestOptions: { json: true, gzip: true, headers: { 'X-RallyIntegrationLibrary': `${pkgInfo.description} v${pkgInfo.version}`, 'X-RallyIntegrationName': pkgInfo.description, 'X-RallyIntegrationVendor': 'Rally Software, Inc.', 'X-RallyIntegrationVersion': pkgInfo.version } } }, options); const apiKey = options && options.apiKey || process.env.RALLY_API_KEY; if (apiKey) { options = _.merge({ requestOptions: { headers: { zsessionid: apiKey }, jar: false } }, options); } else { options = _.merge({ requestOptions: { auth: { user: options && (options.user || options.userName) || process.env.RALLY_USERNAME, pass: options && (options.pass || options.password) || process.env.RALLY_PASSWORD, sendImmediately: false } } }, options); } // Allow dependency injection of the Request class for testing const RequestClass = options && options.RequestClass || Request; this.request = new RequestClass(options); } /** Create a new object @param {object} options - The create options (required) - @member {string} type - The type to be created, e.g. defect, hierarchicalrequirement, etc. (required) - @member {object} data - Key/value pairs of data with which to populate the new object (required) - @member {object} scope - the default scoping to use. if not specified server default will be used. - @member {ref} scope.workspace - the workspace - @member {string/string[]} fetch - the fields to include on the returned record - @member {object} requestOptions - Additional options to be applied to the request: https://github.com/mikeal/request (optional) @param {function} callback - A callback to be called when the operation completes - @param {string[]} errors - Any errors which occurred - @param {object} result - the operation result @return {promise} */ create(options, callback) { const postBody = {}; postBody[options.type] = options.data; return this.request.post(_.merge({ url: `/${options.type}/create`, json: postBody }, options.requestOptions, optionsToRequestOptions(options)), callback); } /** Update an object @param {object} options - The update options (required) - @member {string} ref - The ref of the object to update, e.g. /defect/12345 (required) - @member {object} data - Key/value pairs of data with which to update object (required) - @member {object} scope - the default scoping to use. if not specified server default will be used. - @member {ref} scope.workspace - the workspace - @member {string/string[]} fetch - the fields to include on the returned record - @member {object} requestOptions - Additional options to be applied to the request: https://github.com/mikeal/request (optional) @param {function} callback - A callback to be called when the operation completes - @param {string[]} errors - Any errors which occurred - @param {object} result - the operation result @return {promise} */ update(options, callback) { const relativeRef = refUtils.getRelative(options.ref); if (!relativeRef) { const error = new Error(`Invalid ref: ${options.ref}`); error.errors = [`Invalid ref: ${options.ref}`]; return Promise.reject(error); } const postBody = {}; postBody[refUtils.getType(options.ref)] = options.data; return this.request.put(_.merge({ url: relativeRef, json: postBody }, options.requestOptions, optionsToRequestOptions(options)), callback); } /** Delete an object @param {object} options - The delete options (required) - @member {string} ref - The ref of the object to delete, e.g. /defect/1234 - @member {object} scope - the default scoping to use. if not specified server default will be used. - @member {ref} scope.workspace - the workspace - @member {object} requestOptions - Additional options to be applied to the request: https://github.com/mikeal/request (optional) @param {function} callback - A callback to be called when the operation completes - @param {string[]} errors - Any errors which occurred - @param {object} result - the operation result @return {promise} */ del(options, callback) { const relativeRef = refUtils.getRelative(options.ref); if (!relativeRef) { const error = new Error(`Invalid ref: ${options.ref}`); error.errors = [`Invalid ref: ${options.ref}`]; return Promise.reject(error); } return this.request.del(_.merge({ url: relativeRef }, options.requestOptions, optionsToRequestOptions(options)), callback); } /** Get an object @param {object} options - The get options (required) - @member {string} ref - The ref of the object to get, e.g. /defect/12345 (required) - @member {object} scope - the default scoping to use. if not specified server default will be used. - @member {ref} scope.workspace - the workspace - @member {string/string[]} fetch - the fields to include on the returned record - @member {object} requestOptions - Additional options to be applied to the request: https://github.com/mikeal/request (optional) @param {function} callback - A callback to be called when the operation completes - @param {string[]} errors - Any errors which occurred - @param {object} result - the operation result @return {promise} */ get(options, callback) { const relativeRef = refUtils.getRelative(options.ref); if (!relativeRef) { const error = new Error(`Invalid ref: ${options.ref}`); error.errors = [`Invalid ref: ${options.ref}`]; return Promise.reject(error); } const getPromise = this.request.get(_.merge({ url: relativeRef }, options.requestOptions, optionsToRequestOptions(options))).then(function (result) { return { Errors: result && result.Errors || [], Warnings: result && result.Warnings || [], Object: _.omit(result, ['Errors', 'Warnings']) }; }); callbackify(getPromise, callback); return getPromise; } /** Query for objects @param {object} options - The query options (required) - @member {string} ref - The ref of the collection to query, e.g. /defect/12345/tasks (required if type not specified) - @member {string} type - The type to query, e.g. defect, hierarchicalrequirement (required if ref not specified) - @member {object} scope - the default scoping to use. if not specified server default will be used. - @member {ref} scope.workspace - the workspace - @member {ref} scope.project - the project, or null to include entire workspace - @member {ref} scope.up - true to include parent project data, false otherwise - @member {ref} scope.down - true to include child project data, false otherwise - @member {int} start - the 1 based start index - @member {int} pageSize - the page size, 1 - 200 (default=200) - @member {int} limit - the maximum number of records to return - @member {string/string[]} fetch - the fields to include on each returned record - @member {string/string[]} order - the order by which to sort the results - @member {string/query} query - a query to filter the result set - @member {object} requestOptions - Additional options to be applied to the request: https://github.com/mikeal/request (optional) @param {function} callback - A callback to be called when the operation completes - @param {string[]} errors - Any errors which occurred - @param {object} result - the operation result @return {promise} */ query(options, callback) { const self = this; options = _.merge({ start: 1, pageSize: 200 }, options); // Early return for limit=0 - no API calls needed if (options.limit === 0) { const emptyResult = { Errors: [], Warnings: [], Results: [], StartIndex: options.start, PageSize: 0, TotalResultCount: 0 }; callbackify(Promise.resolve(emptyResult), callback); return Promise.resolve(emptyResult); } // Performance optimization: Calculate optimal page size for better network efficiency let optimalPageSize = Math.max(1, options.pageSize); // Ensure minimum pageSize of 1 if (options.limit !== undefined && options.limit > 0) { // Use smaller page size for small limits to reduce over-fetching optimalPageSize = Math.min(optimalPageSize, options.limit); } const requestOptions = _.merge({ url: refUtils.getRelative(options.ref) || `/${options.type}`, qs: { start: options.start, pagesize: optimalPageSize } }, options.requestOptions, optionsToRequestOptions(options)); if (_.isArray(options.order)) { requestOptions.qs.order = options.order.join(','); } else if (_.isString(options.order)) { requestOptions.qs.order = options.order; } if (options.query) { requestOptions.qs.query = options.query.toQueryString && options.query.toQueryString() || options.query; } let results = []; let totalFetched = 0; function loadRemainingPages(result) { const pageResults = result.Results; // Performance optimization: Use push.apply instead of concat to avoid array copying if (pageResults && pageResults.length > 0) { // Check if we need to limit the page results to respect the overall limit if (options.limit !== undefined) { const remainingNeeded = options.limit - totalFetched; if (remainingNeeded <= 0) { // Early exit: we already have enough results result.Results = results; result.StartIndex = options.start; result.PageSize = results.length; return result; } if (pageResults.length > remainingNeeded) { // Trim the page results to exactly what we need results.push.apply(results, pageResults.slice(0, remainingNeeded)); totalFetched += remainingNeeded; // Early exit: we have exactly what we need result.Results = results; result.StartIndex = options.start; result.PageSize = results.length; return result; } } results.push.apply(results, pageResults); totalFetched += pageResults.length; } // Validate pagination parameters to prevent infinite loops const isValidPagination = result.StartIndex && typeof result.StartIndex === 'number' && options.pageSize && options.pageSize > 0 && result.TotalResultCount && typeof result.TotalResultCount === 'number'; // Enhanced termination condition with early exit for limits const hasMoreData = result.StartIndex + options.pageSize <= result.TotalResultCount; const withinLimit = options.limit === undefined || totalFetched < options.limit; if (isValidPagination && hasMoreData && withinLimit) { // Performance optimization: Reuse base request options object const nextPageOptions = { url: requestOptions.url, qs: Object.assign({}, requestOptions.qs, { start: result.StartIndex + options.pageSize }) }; return self.request.get(nextPageOptions).then(loadRemainingPages); } else { // Final result preparation - no additional slicing needed due to early exits above result.Results = results; result.StartIndex = options.start; result.PageSize = results.length; return result; } } const queryPromise = this.request.get(requestOptions).then(loadRemainingPages); callbackify(queryPromise, callback); return queryPromise; } /** Query for objects with streaming/async iteration support for large datasets @param {object} options - The query options (same as query method) @param {function} onPageCallback - Called for each page of results: (pageResults, pageInfo) => boolean|Promise<boolean> - Return true to continue, false to stop iteration @param {function} callback - A callback to be called when the operation completes @return {promise} */ queryStream(options, onPageCallback, callback) { const self = this; options = _.merge({ start: 1, pageSize: 200 }, options); // Early return for limit=0 - no API calls needed if (options.limit === 0) { const emptyResult = { totalProcessed: 0, completed: true }; callbackify(Promise.resolve(emptyResult), callback); return Promise.resolve(emptyResult); } // Performance optimization: Calculate optimal page size let optimalPageSize = Math.max(1, options.pageSize); // Ensure minimum pageSize of 1 if (options.limit !== undefined && options.limit > 0) { optimalPageSize = Math.min(optimalPageSize, options.limit); } const requestOptions = _.merge({ url: refUtils.getRelative(options.ref) || `/${options.type}`, qs: { start: options.start, pagesize: optimalPageSize } }, options.requestOptions, optionsToRequestOptions(options)); if (_.isArray(options.order)) { requestOptions.qs.order = options.order.join(','); } else if (_.isString(options.order)) { requestOptions.qs.order = options.order; } if (options.query) { requestOptions.qs.query = options.query.toQueryString && options.query.toQueryString() || options.query; } let totalProcessed = 0; async function processPages(result) { const pageResults = result.Results; if (pageResults && pageResults.length > 0) { // Apply limit to page results if necessary let limitedPageResults = pageResults; if (options.limit !== undefined) { const remainingNeeded = options.limit - totalProcessed; if (remainingNeeded <= 0) { return { totalProcessed, completed: true }; } if (pageResults.length > remainingNeeded) { limitedPageResults = pageResults.slice(0, remainingNeeded); } } // Call the page callback const pageInfo = { startIndex: result.StartIndex, pageSize: limitedPageResults.length, totalResultCount: result.TotalResultCount, totalProcessed: totalProcessed + limitedPageResults.length }; const continueProcessing = await onPageCallback(limitedPageResults, pageInfo); totalProcessed += limitedPageResults.length; if (!continueProcessing) { return { totalProcessed, completed: false }; } // Check if we've reached the limit if (options.limit !== undefined && totalProcessed >= options.limit) { return { totalProcessed, completed: true }; } } // Check if there are more pages const isValidPagination = result.StartIndex && typeof result.StartIndex === 'number' && options.pageSize && options.pageSize > 0 && result.TotalResultCount && typeof result.TotalResultCount === 'number'; const hasMoreData = result.StartIndex + options.pageSize <= result.TotalResultCount; const withinLimit = options.limit === undefined || totalProcessed < options.limit; if (isValidPagination && hasMoreData && withinLimit) { const nextPageOptions = { url: requestOptions.url, qs: Object.assign({}, requestOptions.qs, { start: result.StartIndex + options.pageSize }) }; const nextResult = await self.request.get(nextPageOptions); return processPages(nextResult); } else { return { totalProcessed, completed: true }; } } const queryPromise = this.request.get(requestOptions).then(processPages); callbackify(queryPromise, callback); return queryPromise; } /** Query for objects with batch processing support @param {object} options - The query options (same as query method) @param {number} batchSize - Number of results to process in each batch (default: pageSize) @param {function} onBatchCallback - Called for each batch: (batchResults, batchInfo) => boolean|Promise<boolean> @param {function} callback - A callback to be called when the operation completes @return {promise} */ queryBatch(options, batchSize, onBatchCallback, callback) { // Handle overloaded parameters if (typeof batchSize === 'function') { callback = onBatchCallback; onBatchCallback = batchSize; batchSize = options.pageSize || 200; } const batches = []; let currentBatch = []; let totalProcessed = 0; return this.queryStream(options, async (pageResults, pageInfo) => { // Add page results to current batch for (const result of pageResults) { currentBatch.push(result); // Process batch when it reaches the desired size if (currentBatch.length >= batchSize) { const batchInfo = { batchNumber: batches.length + 1, batchSize: currentBatch.length, totalProcessed: totalProcessed + currentBatch.length, totalResultCount: pageInfo.totalResultCount }; const continueProcessing = await onBatchCallback([...currentBatch], batchInfo); batches.push(currentBatch); totalProcessed += currentBatch.length; currentBatch = []; if (!continueProcessing) { return false; } } } return true; }, callback).then(async streamResult => { // Process any remaining items in the final batch if (currentBatch.length > 0) { const batchInfo = { batchNumber: batches.length + 1, batchSize: currentBatch.length, totalProcessed: totalProcessed + currentBatch.length, totalResultCount: streamResult.totalProcessed }; await onBatchCallback([...currentBatch], batchInfo); batches.push(currentBatch); totalProcessed += currentBatch.length; } return { totalProcessed, totalBatches: batches.length, completed: streamResult.completed }; }); } /** Adds items to a collection @param {object} options - The add options (required) - @member {string} ref - The ref of the collection to update, e.g. /user/12345 (required) - @member {string} collection - The name of the collection to update, e.g. 'TeamMemberships (required) - @member {object} data - [{_ref: objectRef}, {Name:"Joe"}], things to be added to the collection (required) - @member {string/string[]} fetch - the fields to include on the returned records - @member {object} scope - the default scoping to use. if not specified server default will be used. - @member {ref} scope.workspace - the workspace - @member {object} requestOptions - Additional options to be applied to the request: https://github.com/mikeal/request (optional) @param {function} callback - A callback to be called when the operation completes - @param {string[]} errors - Any errors which occurred - @param {object} result - the operation result @return {promise} */ add(options, callback) { return collectionPost.call(this, options, 'add', callback); } /** Remove items from a collection @param {object} options - The remove options (required) - @member {string} ref - The ref of the collection to update, e.g. /user/12345 (required) - @member {string} collection - The name of the collection to update, e.g. 'TeamMemberships (required) - @member {object} data - [{_ref: objectRef}], where the objectRefs are to be removed from the collection (required) - @member {string/string[]} fetch - the fields to include on the returned records - @member {object} scope - the default scoping to use. if not specified server default will be used. - @member {ref} scope.workspace - the workspace - @member {object} requestOptions - Additional options to be applied to the request: https://github.com/mikeal/request (optional) @param {function} callback - A callback to be called when the operation completes - @param {string[]} errors - Any errors which occurred - @param {object} result - the operation result @return {promise} */ remove(options, callback) { return collectionPost.call(this, options, 'remove', callback); } }