ibm-rally-node
Version:
Customized Rally REST Toolkit for Node.js
554 lines (530 loc) • 23.3 kB
JavaScript
/**
@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);
}
}