/* eslint-disable object-shorthand */ var _ = require('lodash').noConflict(), constants = require('../../constants'), v1Common = require('../../common/v1'), util = require('../../util'), headersCommentPrefix = '//', IS_SUBFOLDER = Symbol('_postman_isSubFolder'), /** * A constructor that is capable of being used for one-off conversions of requests, and folders. * * @param {Object} options - The set of options for builder construction. * @class Builders * @constructor */ Builders = function (options) { this.options = options || {}; }, /** * Parse formdata & urlencoded request data. * - filter params missing property `key` * - handle file type param value * -cleanup `enabled` & `description` * * @param {Object[]} data - Data to parse. * @param {?Boolean} [retainEmpty] - To retain empty values or not. * @returns {Object[]} - Parsed data. */ parseFormData = function (data, retainEmpty) { if (!(Array.isArray(data) && data.length)) { return []; } var formdata = [], i, ii, param; for (i = 0, ii = data.length; i < ii; i++) { // clone param to avoid mutating data. // eslint-disable-next-line prefer-object-spread param = Object.assign({}, data[i]); // skip if param is missing property `key`, // `key` is a required property, value can be null/undefined. // because in a FormParam property lists, key is used for indexing. if (_.isNil(param.key)) { continue; } // for file `type`, set `value` to `src` if (param.type === 'file' && !param.src && param.value) { param.src = (_.isString(param.value) || _.isArray(param.value)) ? param.value : null; delete param.value; } // `hasOwnProperty` check ensures that it don't delete undefined property: `enabled` if (Object.prototype.hasOwnProperty.call(param, 'enabled')) { // set `disabled` flag if `enabled` is false. param.enabled === false && (param.disabled = true); delete param.enabled; // cleanup } // Prevent empty descriptions from showing up in the converted results. This keeps collections clean. util.cleanEmptyValue(param, 'description', retainEmpty); formdata.push(param); } return formdata; }; _.assign(Builders.prototype, { /** * Constructs a V2 compatible "info" object from a V1 Postman Collection * * @param {Object} collectionV1 - A v1 collection to derive collection metadata from. * @returns {Object} - The resultant v2 info object. */ info: function (collectionV1) { var info = { _postman_id: collectionV1.id || util.uid(), name: collectionV1.name }; if (collectionV1.description) { info.description = collectionV1.description; } else if (this.options.retainEmptyValues) { info.description = null; } info.schema = constants.SCHEMA_V2_URL; return info; }, /** * Facilitates sanitized variable transformations across all levels for v1 collection normalization. * * @param {Object} entity - The wrapper object containing variable definitions. * @param {?Object} options - The set of options for the current variable transformation. * @param {?Object} options.fallback - The set of fallback values to be applied when no variables exist. * @param {?Boolean} options.noDefaults - When set to true, no defaults are applied. * @param {?Boolean} options.retainIds - When set to true, ids are left as is. * @returns {Object[]} - The set of sanitized variables. */ variable: function (entity, options) { return util.handleVars(entity, options); }, /** * Constructs a V2 compatible URL object from a V1 request * * @param {Object} requestV1 - The source v1 request to extract the URL from. * @returns {String|Object} - The resultant URL. */ url: function (requestV1) { var queryParams = [], pathVariables = [], traversedVars = {}, queryParamAltered, retainIds = this.options.retainIds, parsed = util.urlparse(requestV1.url), retainEmpty = this.options.retainEmptyValues; // add query params _.forEach(requestV1.queryParams, function (queryParam) { (queryParam.enabled === false) && (queryParam.disabled = true); delete queryParam.enabled; util.cleanEmptyValue(queryParam, 'description', retainEmpty); if (_.has(queryParam, 'equals')) { if (queryParam.equals) { (queryParam.value === null) && (queryParam.value = ''); } else { // = is not appended when the value is null. However, // non empty value should be preserved queryParam.value = queryParam.value || null; } queryParamAltered = true; delete queryParam.equals; } queryParams.push(queryParam); }); // only add query params from URL if not given explicitly if (!_.size(queryParams)) { // parsed query params are taken from the url, so no descriptions are available from them queryParams = parsed.query; } // Merge path variables _.forEach(requestV1.pathVariableData, function (pathVariable) { pathVariable = _.clone(pathVariable); util.cleanEmptyValue(pathVariable, 'description', retainEmpty); if (!retainIds && pathVariable.id) { delete pathVariable.id; } pathVariables.push(pathVariable); traversedVars[pathVariable.key] = true; }); // pathVariables in v1 are of the form {foo: bar}, so no descriptions can be obtained from them _.forEach(requestV1.pathVariables, function (value, key) { !traversedVars[key] && pathVariables.push({ value: value, key: key }); }); !_.isEmpty(queryParams) && (parsed.query = queryParams); !_.isEmpty(pathVariables) && (parsed.variable = pathVariables); // If the query params have been altered, update the raw stringified URL queryParamAltered && (parsed.raw = util.urlunparse(parsed)); // return the objectified URL only if query param or path variable descriptions are present, string otherwise return (parsed.query || parsed.variable) ? parsed : (parsed.raw || requestV1.url); }, /** * Extracts the HTTP Method from a V1 request * * @param {Object} requestV1 - The v1 request to extract the request method from. * @returns {String} - The extracted request method. */ method: function (requestV1) { return requestV1.method; }, /** * Constructs an array of Key-Values from a raw HTTP Header string. * * @param {Object} requestV1 - The v1 request to extract header information from. * @returns {Object[]} - A list of header definition objects. */ header: function (requestV1) { if (_.isArray(requestV1.headers)) { return requestV1.headers; } var headers = [], traversed = {}, headerData = requestV1.headerData || [], retainEmpty = this.options.retainEmptyValues; _.forEach(headerData, function (header) { if (_.startsWith(header.key, headersCommentPrefix) || (header.enabled === false)) { header.disabled = true; header.key = header.key.replace(headersCommentPrefix, '').trim(); } // prevent empty header descriptions from showing up in converted results. This keeps the collections clean util.cleanEmptyValue(header, 'description', retainEmpty); delete header.enabled; headers.push(header); // @todo Improve this sequence to account for multi-valued headers traversed[header.key] = true; }); // requestV1.headers is a string, so no descriptions can be obtained from it _.forEach(v1Common.parseHeaders(requestV1.headers), function (header) { !traversed[header.key] && headers.push(header); }); return headers; }, /** * Constructs a V2 Request compatible "body" object from a V1 Postman request * * @param {Object} requestV1 - The v1 request to extract the body from. * @returns {{mode: *, content: (*|string)}} */ body: function (requestV1) { var modes = { binary: 'file', graphql: 'graphql', params: 'formdata', raw: 'raw', urlencoded: 'urlencoded' }, data = {}, rawModeData, graphqlModeData, dataMode = modes[requestV1.dataMode], retainEmpty = this.options.retainEmptyValues, bodyOptions = {}, mode, // flag indicating that all the props which holds request body data // i.e, data (urlencoded, formdata), rawModeData (raw, file) and graphqlModeData (graphql) // are empty. emptyBody = _.isEmpty(requestV1.data) && _.isEmpty(requestV1.rawModeData) && _.isEmpty(requestV1.graphqlModeData); // set body to null if: // 1. emptyBody is true and dataMode is unset // 2. dataMode is explicitly set to null // @note the explicit null check is added to ensure that body is not set // in case the dataMode was set to null by the app. if ((!dataMode && emptyBody) || requestV1.dataMode === null) { return retainEmpty ? null : undefined; } // set `rawModeData` if its a string if (_.isString(requestV1.rawModeData)) { rawModeData = requestV1.rawModeData; } // check if `rawModeData` is an array like: ['rawModeData'] else if (Array.isArray(requestV1.rawModeData) && requestV1.rawModeData.length === 1 && _.isString(requestV1.rawModeData[0])) { rawModeData = requestV1.rawModeData[0]; } // set graphqlModeData if its not empty if (!_.isEmpty(requestV1.graphqlModeData)) { graphqlModeData = requestV1.graphqlModeData; } // set data.mode. // if dataMode is not set, infer from data or rawModeData or graphqlModeData if (dataMode) { data.mode = dataMode; } // at this point we are sure that the body is not empty so let's // infer the data mode. // @note its possible that multiple body types are set e.g, both // rawModeData and graphqlModeData are set. So, the priority will be: // raw -> formdata -> graphql (aligned with pre-graphql behaviour). // // set `formdata` if rawModeData is not set and data is an array // `data` takes higher precedence over `rawModeData`. else if (!rawModeData && Array.isArray(requestV1.data || requestV1.rawModeData)) { data.mode = 'formdata'; } // set `graphql` if graphqlModeData is set else if (!rawModeData && graphqlModeData) { data.mode = 'graphql'; } // set `raw` mode as default else { data.mode = 'raw'; } if (data.mode === 'raw') { if (rawModeData) { data[data.mode] = rawModeData; } else if (_.isString(requestV1.data)) { data[data.mode] = requestV1.data; } else { // empty string instead of retainEmpty check to have parity with other modes. data[data.mode] = ''; } } else if (data.mode === 'graphql') { data[data.mode] = graphqlModeData; } else if (data.mode === 'file') { // rawModeData can be string or undefined. data[data.mode] = { src: rawModeData }; } else { // parse data for formdata or urlencoded data modes. // `rawModeData` is checked in case its of type `data`. data[data.mode] = parseFormData(requestV1.data || requestV1.rawModeData, retainEmpty); } if (requestV1.dataOptions) { // Convert v1 mode to v2 mode for (mode in modes) { if (!_.isEmpty(requestV1.dataOptions[mode])) { bodyOptions[modes[mode]] = requestV1.dataOptions[mode]; } } !_.isEmpty(bodyOptions) && (data.options = bodyOptions); } if (requestV1.dataDisabled) { data.disabled = true; } else if (retainEmpty) { data.disabled = false; } return data; }, /** * Constructs a V2 "events" object from a V1 Postman Request * * @param {Object} entityV1 - The v1 entity to extract script information from. * @returns {Object[]|*} */ event: function (entityV1) { if (!entityV1) { return; } const retainIds = this.options.retainIds; // if prioritizeV2 is true, events is used as the source of truth if ((util.notLegacy(entityV1, 'event') || this.options.prioritizeV2) && !_.isEmpty(entityV1.events)) { // in v1, `events` is regarded as the source of truth if it exists, so handle that first and bail out. // @todo: Improve this to order prerequest events before test events _.forEach(entityV1.events, function (event) { !event.listen && (event.listen = 'test'); // just delete the event.id if retainIds is not set if (!retainIds && event.id) { delete event.id; } if (event.script) { // just delete the script.id if retainIds is not set if (!retainIds && event.script.id) { delete event.script.id; } !event.script.type && (event.script.type = 'text/javascript'); // @todo: Add support for src _.isString(event.script.exec) && (event.script.exec = event.script.exec.split('\n')); } }); return entityV1.events; } var events = []; // @todo: Extract both flows below into a common method if (entityV1.tests) { events.push({ listen: 'test', script: { type: 'text/javascript', exec: _.isString(entityV1.tests) ? entityV1.tests.split('\n') : entityV1.tests } }); } if (entityV1.preRequestScript) { events.push({ listen: 'prerequest', script: { type: 'text/javascript', exec: _.isString(entityV1.preRequestScript) ? entityV1.preRequestScript.split('\n') : entityV1.preRequestScript } }); } return events.length ? events : undefined; }, /** * A number of auth parameter names have changed from V1 to V2. This function calls the appropriate * mapper function, and creates the V2 auth parameter object. * * @param {Object} entityV1 - The v1 entity to derive auth information from. * @param {?Object} options - The set of options for the current auth cleansing operation. * @param {?Boolean} [options.includeNoauth=false] - When set to true, noauth is set to null. * @returns {{type: *}} */ auth: function (entityV1, options) { if (!entityV1) { return; } if ((util.notLegacy(entityV1, 'auth') || this.options.prioritizeV2) && entityV1.auth) { return util.authArrayToMap(entityV1, options); } if (!entityV1.currentHelper || (entityV1.currentHelper === null) || (entityV1.currentHelper === 'normal')) { return; } var params, type = v1Common.authMap[entityV1.currentHelper] || entityV1.currentHelper, auth = { type: type }; // Some legacy versions of the App export Helper Attributes as a string. if (_.isString(entityV1.helperAttributes)) { try { entityV1.helperAttributes = JSON.parse(entityV1.helperAttributes); } catch (e) { return; } } if (entityV1.helperAttributes && util.authMappersFromLegacy[entityV1.currentHelper]) { params = util.authMappersFromLegacy[entityV1.currentHelper](entityV1.helperAttributes); } params && (auth[type] = params); return auth; }, /** * Creates a V2 format request from a V1 Postman Collection Request * * @param {Object} requestV1 - The v1 request to be transformed. * @returns {Object} - The converted v2 request. */ request: function (requestV1) { var self = this, request = {}, retainEmpty = self.options.retainEmptyValues, units = ['auth', 'method', 'header', 'body', 'url']; units.forEach(function (unit) { request[unit] = self[unit](requestV1); }); if (requestV1.description) { request.description = requestV1.description; } else if (retainEmpty) { request.description = null; } return request; }, /** * Converts a V1 cookie to a V2 cookie. * * @param {Object} cookieV1 - The v1 cookie object to convert. * @returns {{expires: string, hostOnly: *, httpOnly: *, domain: *, path: *, secure: *, session: *, value: *}} */ cookie: function (cookieV1) { return { expires: (new Date(cookieV1.expirationDate * 1000)).toString(), hostOnly: cookieV1.hostOnly, httpOnly: cookieV1.httpOnly, domain: cookieV1.domain, path: cookieV1.path, secure: cookieV1.secure, session: cookieV1.session, value: cookieV1.value, key: cookieV1.name }; }, /** * Gets the saved request for the given response, and handles edge cases between Apps & Sync * * Handles a lot of edge cases, so the code is not very clean. * * The Flow followed here is: * * If responseV1.requestObject is present * If it is a string * Try parsing it as JSON * If parsed, * return it * else * It is a request ID * If responseV1.request is present * If it is a string * Try parsing it as JSON * If parsed, * return it * else * It is a request ID * Look up the collection for the request ID and return it, or return undefined. * * @param {Object} responseV1 - The v1 response to be converted. * @returns {Object} - The converted saved request, in v2 format. */ savedRequest: function (responseV1) { var self = this, associatedRequestId; if (responseV1.requestObject) { if (_.isString(responseV1.requestObject)) { try { return JSON.parse(responseV1.requestObject); } catch (e) { // if there was an error parsing it as JSON, it's probably an ID, so store it in the ID variable associatedRequestId = responseV1.requestObject; } } else { return responseV1.requestObject; } } if (responseV1.request) { if (_.isString(responseV1.request)) { try { return JSON.parse(responseV1.request); } catch (e) { // if there was an error parsing it as JSON, it's probably an ID, so store it in the ID variable associatedRequestId = responseV1.request; } } else { return responseV1.request; } } // we have a request ID return associatedRequestId && _.get(self, ['cache', associatedRequestId]); }, /** * Since a V2 response contains the entire associated request that was sent, creating the response means it * also must use the V1 request. * * @param {Object} responseV1 - The response object to convert from v1 to v2. * @returns {Object} - The v2 response object. */ singleResponse: function (responseV1) { var response = {}, self = this, originalRequest; originalRequest = self.savedRequest(responseV1); // add ids to the v2 result only if both: the id and retainIds are truthy. // this prevents successive exports to v2 from being overwhelmed by id diffs self.options.retainIds && (response.id = responseV1.id || util.uid()); response.name = responseV1.name || 'response'; response.originalRequest = originalRequest ? self.request(originalRequest) : undefined; response.status = responseV1.responseCode && responseV1.responseCode.name || undefined; response.code = responseV1.responseCode && Number(responseV1.responseCode.code) || undefined; response._postman_previewlanguage = responseV1.language; response._postman_previewtype = responseV1.previewType; response.header = responseV1.headers; response.cookie = _.map(responseV1.cookies, function (cookie) { return self.cookie(cookie); }); response.responseTime = responseV1.time; response.body = responseV1.text; return response; }, /** * Constructs an array of "sample" responses (compatible with a V2 collection) * from a Postman Collection V1 Request. * * If response ordering via `responses_order` field is present, * ensure the ordering is respected while constructing responses array. * * @param {Object} requestV1 - The v1 request object to extract response information from. * @returns {Object[]} - The list of v2 response definitions. */ response: function (requestV1) { var self = this, responses = _.get(requestV1, 'responses', []), responsesCache = _.keyBy(responses, 'id'), responses_order = _.get(requestV1, 'responses_order', []); // If ordering of responses is not available // Create a default ordering using the `responses` field if (!(responses_order && responses_order.length)) { responses_order = _.map(responses, 'id'); } // Filter out any response id that is not available responses_order = _.filter(responses_order, function (responseId) { return _.has(responsesCache, responseId); }); return _.map(responses_order, function (responseId) { return self.singleResponse(responsesCache[responseId]); }); }, /** * Creates a V2 compatible ``item`` from a V1 Postman Collection Request * * @param {Object} requestV1 - Postman collection V1 request. * @returns {Object} - The converted request object, in v2 format. */ singleItem: function (requestV1) { if (!requestV1) { return; } var self = this, retainIds = self.options.retainIds, units = ['request', 'response'], variable = self.variable(requestV1, { retainIds: retainIds }), item = { name: requestV1.name || '', // Inline building to avoid additional function call event: self.event(requestV1) }; retainIds && (item.id = requestV1.id || util.uid()); // add protocolProfileBehavior property from requestV1 to the item util.addProtocolProfileBehavior(requestV1, item); units.forEach(function (unit) { item[unit] = self[unit](requestV1); }); variable && variable.length && (item.variable = variable); return item; }, /** * Constructs an array of Items & ItemGroups compatible with the V2 format. * * @param {Object} collectionV1 - The v1 collection to derive folder information from. * @returns {Object[]} - The list of item group definitions. */ itemGroups: function (collectionV1) { var self = this, items = [], itemGroupCache = {}, retainEmpty = self.options.retainEmptyValues; // Read all folder data, and prep it so that we can throw subfolders in the right places itemGroupCache = _.reduce(collectionV1.folders, function (accumulator, folder) { if (!folder) { return accumulator; } var retainIds = self.options.retainIds, auth = self.auth(folder), event = self.event(folder), variable = self.variable(folder, { retainIds: retainIds }), result = { name: folder.name, item: [] }; retainIds && (result.id = folder.id || util.uid()); if (folder.description) { result.description = folder.description; } else if (retainEmpty) { result.description = null; } (auth || (auth === null)) && (result.auth = auth); event && (result.event = event); variable && variable.length && (result.variable = variable); util.addProtocolProfileBehavior(folder, result); accumulator[folder.id] = result; return accumulator; }, {}); // Populate each ItemGroup with subfolders _.forEach(collectionV1.folders, function (folderV1) { if (!folderV1) { return; } var itemGroup = itemGroupCache[folderV1.id], hasSubfolders = folderV1.folders_order && folderV1.folders_order.length, hasRequests = folderV1.order && folderV1.order.length; // Add subfolders hasSubfolders && _.forEach(folderV1.folders_order, function (subFolderId) { if (!itemGroupCache[subFolderId]) { // todo: figure out what to do when a collection contains a subfolder ID, // but the subfolder is not actually there. return; } itemGroupCache[subFolderId][IS_SUBFOLDER] = true; itemGroup.item.push(itemGroupCache[subFolderId]); }); // Add items hasRequests && _.forEach(folderV1.order, function (requestId) { if (!self.cache[requestId]) { // todo: what do we do here?? return; } itemGroup.item.push(self.singleItem(self.cache[requestId])); }); }); // This compromises some self-healing, which was originally present, but the performance cost of // doing self-healing the right way is high, so we directly rely on collectionV1.folders_order // The self-healing way would be to iterate over itemGroupCache directly, but preserving the right order // becomes a pain in that case. _.forEach(_.uniq(collectionV1.folders_order || _.map(collectionV1.folders, 'id')), function (folderId) { var itemGroup = itemGroupCache[folderId]; itemGroup && !_.get(itemGroup, IS_SUBFOLDER) && items.push(itemGroup); }); // This is useful later self.itemGroupCache = itemGroupCache; return _.compact(items); }, /** * Creates a V2 compatible array of items from a V1 Postman Collection * * @param {Object} collectionV1 - A Postman Collection object in the V1 format. * @returns {Object[]} - The list of item groups (folders) in v2 format. */ item: function (collectionV1) { var self = this, requestsCache = _.keyBy(collectionV1.requests, 'id'), allRequests = _.map(collectionV1.requests, 'id'), result; self.cache = requestsCache; result = self.itemGroups(collectionV1); _.forEach(_.intersection(collectionV1.order, allRequests), function (requestId) { var request = self.singleItem(requestsCache[requestId]); request && (result.push(request)); }); return result; } }); module.exports = { input: '1.0.0', output: '2.0.0', Builders: Builders, /** * Converts a single V1 request to a V2 item. * * @param {Object} request - The v1 request to convert to v2. * @param {Object} options - The set of options for v1 -> v2 conversion. * @param {Function} callback - The function to be invoked when the conversion has completed. */ convertSingle: function (request, options, callback) { var builders = new Builders(options), converted, err; try { converted = builders.singleItem(_.cloneDeep(request)); } catch (e) { err = e; } if (callback) { return callback(err, converted); } if (err) { throw err; } return converted; }, /** * Converts a single V1 Response to a V2 Response. * * @param {Object} response - The v1 response to convert to v2. * @param {Object} options - The set of options for v1 -> v2 conversion. * @param {Function} callback - The function to be invoked when the conversion has completed. */ convertResponse: function (response, options, callback) { var builders = new Builders(options), converted, err; try { converted = builders.singleResponse(_.cloneDeep(response)); } catch (e) { err = e; } if (callback) { return callback(err, converted); } if (err) { throw err; } return converted; }, /** * Converts a V1 collection to a V2 collection (performs ID replacement, etc as necessary). * * @param {Object} collection - The v1 collection to convert to v2. * @param {Object} options - The set of options for v1 -> v2 conversion. * @param {Function} callback - The function to be invoked when the conversion has completed. */ convert: function (collection, options, callback) { collection = _.cloneDeep(collection); var auth, event, variable, newCollection = {}, units = ['info', 'item'], builders = new Builders(options), authOptions = { excludeNoauth: true }, varOpts = options && { fallback: options.env, retainIds: options.retainIds }; try { units.forEach(function (unit) { newCollection[unit] = builders[unit](collection); }); (auth = builders.auth(collection, authOptions)) && (newCollection.auth = auth); (event = builders.event(collection)) && (newCollection.event = event); (variable = builders.variable(collection, varOpts)) && (newCollection.variable = variable); util.addProtocolProfileBehavior(collection, newCollection); } catch (e) { if (callback) { return callback(e, null); } throw e; } if (callback) { return callback(null, newCollection); } return newCollection; } };