diff --git a/OPTIONS.md b/OPTIONS.md index b2c4c9439..1ca4f169d 100644 --- a/OPTIONS.md +++ b/OPTIONS.md @@ -17,3 +17,7 @@ validateMetadata|boolean|-|false|Whether to show mismatches for incorrect name a ignoreUnresolvedVariables|boolean|-|false|Whether to ignore mismatches resulting from unresolved variables in the Postman request|VALIDATION strictRequestMatching|boolean|-|false|Whether requests should be strictly matched with schema operations. Setting to true will not include any matches where the URL path segments don't match exactly.|VALIDATION disableOptionalParameters|boolean|-|false|Whether to set optional parameters as disabled|CONVERSION +requireCommonProps|boolean|-|false|Whether to set common schema properties among multiple requests as required.|SPEC_CONVERSION +outputFormat|enum|YAML, JSON|YAML|Select whether to generate the output specification in YAML or the JSON format.|SPEC_CONVERSION +includeExamples|boolean|-|false|Whether to include data present in request as OpenAPI example(s) object.|SPEC_CONVERSION +extractionLevels|integer|-|2|Choose how much deeper common component extraction happen in nested schemas|SPEC_CONVERSION diff --git a/lib/extractCommonComponents.js b/lib/extractCommonComponents.js new file mode 100644 index 000000000..da7380186 --- /dev/null +++ b/lib/extractCommonComponents.js @@ -0,0 +1,259 @@ +const _ = require('lodash'), + utils = require('./schemaUtils'), + mergeAllOf = require('json-schema-merge-allof'), + primitiveTypes = ['string', 'integer', 'number', 'boolean'], + validTypes = ['array', 'object', 'string', 'integer', 'number', 'boolean']; + +/** + * + * @param {String} propNameType String containig property name and type separated by comma (,) + * @returns {String} Property name from input string (first segment) + */ +function getPropName (propNameType) { + return propNameType.split(',')[0]; +} + +/** + * Fetches common properties among schemas and generates common component + * + * @param {Object} commonProp Common property to extract from schemas + * @param {String} schemaKeys Schema keys of schema containing common properties + * @param {Object} allSchemas Map containing all schemas with schema keys + * @returns {Object} Generated common component + */ +function getCommonComponent (commonProp, schemaKeys, allSchemas) { + let schemas = _.map(_.split(schemaKeys, ','), (schemaKey) => { + let schema = _.cloneDeep(allSchemas[schemaKey].schema), + target = schema.allOf ? _.last(schema.allOf) : schema; + + schema.properties = _.pick(target.properties, _.map(commonProp, getPropName)); + return schema; + }); + + // merge all schemas to retain data from all schemas + return mergeAllOf({ + allOf: schemas + }, { + resolvers: { + defaultResolver: (compacted) => { return compacted[0]; } + } + }); +} + +/** + * Creates minified schema from OAS Schema object. + * Also separates nested schemas from parent schema to make extraction process simpler + * + * @param {Object} schema Schema to be minified + * @param {String} key Name/Key of schema to be minified + * @param {Integer} nestingLevel Nesting level till which common components are to be ecxtracted + * @param {Object} allSchemas Map containing all schemas with schema keys + * @returns {*} Converted minified schema + */ +function createMinifiedSchema (schema, key, nestingLevel, allSchemas) { + let minifiedSchema = {}, + names = [], + types = []; + + if (!_.isObject(schema) && !schema.type) { + return; + } + + if (schema.type === 'object') { + // go through all properties + _.forEach(_.get(schema, 'properties'), (propSchema, propName) => { + let type = _.get(propSchema, 'type', 'string'), // default is string + schemaName; + + names.push(propName); + if ((type === 'object' || type === 'array') && nestingLevel > 1) { + schemaName = utils.generateSchemaName(_.camelCase(`${key} ${propName}`), allSchemas); + type = schemaName; + allSchemas[schemaName] = { + schema: propSchema, + minifiedSchema: createMinifiedSchema(propSchema, schemaName, nestingLevel - 1, allSchemas) + }; + } + types.push(type); + }); + + minifiedSchema.object = { names, types }; + } + else if (schema.type === 'array') { + let arrayType = _.get(schema, 'items.type', 'string'), + arraySchema = _.get(schema, 'items'), + schemaName; + + if ((arrayType === 'object' || arrayType === 'array') && nestingLevel > 1) { + schemaName = utils.generateSchemaName(_.camelCase(`${key} Array`), allSchemas); + arrayType = schemaName; + allSchemas[schemaName] = { + schema: arraySchema, + minifiedSchema: createMinifiedSchema(arraySchema, schemaName, nestingLevel - 1, allSchemas) + }; + } + minifiedSchema.array = arrayType; + } + else { + minifiedSchema[schema.type] = _.includes(primitiveTypes, schema.type) ? schema.type : 'string'; + } + return minifiedSchema; +} + +/** + * Iterates over schema properties/items to resolve any unresolved nested child schemas + * + * @param {String} schemaKey Key/Name of schema to be resolved + * @param {Object} components OAS defined components object + * @param {*} allSchemas Map containing all schemas with schema keys + * @returns {*} null + */ +function resolveSchema (schemaKey, components, allSchemas) { + let schema = allSchemas[schemaKey].schema, + minifiedSchema = allSchemas[schemaKey].minifiedSchema, + subSchemas = {}; + + _.forEach(_.get(minifiedSchema, 'object.types', []), (type, index) => { + if (!_.includes(validTypes, type)) { + subSchemas[_.get(minifiedSchema, `object.names[${index}]`)] = type; + } + }); + + if (!_.isObject(schema)) { + return; + } + + if (schema.$ref) { + let refSchemaName = _.split(schema.$ref, '/').pop(); + resolveSchema(refSchemaName, components, allSchemas); + } + + if (minifiedSchema.array && !_.includes(validTypes, minifiedSchema.array)) { + resolveSchema(minifiedSchema.array, components, allSchemas); + schema.items = allSchemas[minifiedSchema.array].schema; + } + + if (schema.allOf && !schema.allOf[schema.allOf.length - 1].$ref) { + schema = schema.allOf[schema.allOf.length - 1]; + } + + _.forEach(schema.properties, (prop, propName) => { + if (subSchemas[propName]) { + resolveSchema(subSchemas[propName], components, allSchemas); + prop = allSchemas[subSchemas[propName]].schema; + } + }); + + // _.forEach(subSchemas, (schemaType, schemaName) => { + // if (_.get(schema, 'properties.' + schemaName)) { + // resolveSchema(subSchemas[propName], allSchemas); + // _.set(schema, 'properties.' + schemaName, allSchemas[schemaType].schema); + // } + // else if (schema.$ref) { + // let refSchemaName = _.split(schema.$ref, '/').pop(); + + // resolveSchema(refSchemaName, allSchemas); + // _.set(allSchemas, 'properties.' + schemaName, allSchemas[schemaType].schema); + // } + // }); +} + +/** + * Extracts common components from given set of schemas and stores it in components object + * Also uses schema inheritance to reference extracted components + * + * @param {Object} schemas Array of OAS schema objects + * @param {Object} components OAS defined components object + * @param {Object} options Options to be used while extraction + * @returns {Object} Schema objects after extraction is done + */ +function extractCommonComponents (schemas, components, options) { + let allSchemas = {}, + allPropsMap = {}, + commonProps = {}, + nestingLevels = options.extractionLevels || 1; + + // flatten schemas based on defined nesting levels till which extraction is to happen + _.forEach(schemas, (schema, key) => { + let minifiedSchema = createMinifiedSchema(schema, key, nestingLevels, allSchemas); + allSchemas[key] = { schema, minifiedSchema }; + }); + + // create property map out of all schema with type object + _.forEach(allSchemas, (schema, key) => { + if (schema.minifiedSchema.object) { + // store property name and type under all properties map + _.forEach(_.zip(schema.minifiedSchema.object.names, schema.minifiedSchema.object.types), (prop) => { + let propNameType = _.join(prop, ','); + allPropsMap[propNameType] = _.concat(allPropsMap[propNameType] || [], key); + }); + } + }); + + // reverse map the common properties with all schemas they occur + _.forEach(allPropsMap, (schemaKeysArr, propNameType) => { + let schemaKeys = _.join(schemaKeysArr, ','); + commonProps[schemaKeys] = _.concat(commonProps[schemaKeys] || [], propNameType); + }); + + // extract common properties into components + _.forEach(commonProps, (commonProp, schemaKeys) => { + let componentName, + commonComponent, + refObject, + firstSchemaKey; + + // for now extract only if more than 3 properties are common + if (commonProp.length >= 3) { + firstSchemaKey = schemaKeys.split(',')[0]; + // generate component name from first schema name encountered + componentName = utils.generateSchemaName(firstSchemaKey + 'SubSchema', components); + + // assign common component + commonComponent = getCommonComponent(commonProp, schemaKeys, allSchemas); + _.set(components, 'schemas.' + componentName, commonComponent); + allSchemas[componentName] = commonComponent; + + refObject = { $ref: '#/components/schemas/' + componentName }; + // remove common properties from schema and use reference instead + _.forEach(_.split(schemaKeys, ','), (schemaKey) => { + _.forEach(commonProp, (propNameType) => { + let target = ''; + allSchemas[schemaKey].schema.allOf && (target = `allOf[${allSchemas[schemaKey].schema.allOf.length - 1}].`); + _.unset(allSchemas[schemaKey].schema, target + 'properties.' + getPropName(propNameType)); + }); + + // insert ref object as second last element + if (allSchemas[schemaKey].schema.allOf) { + allSchemas[schemaKey].schema.allOf.splice(allSchemas[schemaKey].schema.allOf.length - 1, 0, refObject); + + if (_.isEmpty(_.last(allSchemas[schemaKey].schema.allOf).properties)) { + allSchemas[schemaKey].schema.allOf.pop(); + } + } + // use entire schema as ref if all properties are common + else if (_.isEmpty(allSchemas[schemaKey].schema.properties) && + _.keys(allSchemas[schemaKey].schema).length <= 2) { + allSchemas[schemaKey].schema = refObject; + } + // use inheritance to represent extracted component in schema + else { + allSchemas[schemaKey].schema = { allOf: [refObject, allSchemas[schemaKey].schema] }; + } + }); + } + }); + + // resolve schema (putting back flattened ) + _.forEach(schemas, (schema, schemaKey) => { + resolveSchema(schemaKey, components, allSchemas); + + schema = allSchemas[schemaKey].schema; + }); + + return _.mapValues(schemas, (schema, schemaKey) => { + return allSchemas[schemaKey].schema; + }); +} + +module.exports = extractCommonComponents; diff --git a/lib/mergeJsonSchemas.js b/lib/mergeJsonSchemas.js new file mode 100644 index 000000000..c1a4fdbb2 --- /dev/null +++ b/lib/mergeJsonSchemas.js @@ -0,0 +1,207 @@ +const _ = require('lodash'), + mergeAllOfSchemas = require('json-schema-merge-allof'); + +/** + * This function merges multiple Json schemas into singular Json Schema based on defined options. + * + * @param {Array} schemas - Array of Json Schemas to be merged + * @param {*} options - Options + * @returns {*} - Single Merged Json Schema + */ +function mergeJsonSchemas (schemas, options) { + let mergedSchema; + + try { + mergedSchema = mergeAllOfSchemas({ + allOf: schemas + }, { + resolvers: { + // for keywords in OpenAPI schema that are not standard defined JSON schema keywords, use default resolver + defaultResolver: (compacted) => { return compacted[0]; }, + // for resolving conflicts with differnt "type" of schema + type: (compacted) => { + // existing handling defined by json-schema-merge-all-of module for keyword 'type' + if (compacted.some(Array.isArray)) { + var normalized = compacted.map(function(val) { + return Array.isArray(val) ? val : [val]; + }), + // eslint-disable-next-line prefer-spread + common = intersection.apply(null, normalized); + + if (common.length === 1) { + return common[0]; + } + else if (common.length > 1) { + return uniq(common); + } + } + // added handling for keyword 'type' when multiple of it present + else if (compacted.length > 0) { + let counts = {}, + maxCount = { keyword: compacted[0], count: 1 }; + + // prioritize object > array > primitive data types + if (compacted.some((element) => { return element === 'object'; })) { + return 'object'; + } + else if (compacted.some((element) => { return element === 'array'; })) { + return 'array'; + } + for (let i = 0; i < compacted.length; i++) { + counts[compacted[i]] = 1 + (counts[compacted[i]] || 0); + // keep track of keyword with max occurance + if (counts[compacted[i]] > maxCount.count) { + maxCount.count = counts[compacted[i]]; + maxCount.keyword = compacted[i]; + } + } + return maxCount.keyword; + } + }, + // for resolving proprties keyword (this is needed to apply required keyword according to options) + properties(values, key, mergers, moduleOptions) { + // all defined function are same from module + let keys = (obj) => { + if (_.isPlainObject(obj) || Array.isArray(obj)) { + return Object.keys(obj); + } + return []; + }, + withoutArr = (arr, ...rest) => { return _.without.apply(null, [arr].concat(_.flatten(rest))); }, + allUniqueKeys = (arr) => { return _.uniq(_.flattenDeep(arr.map(keys))); }, + getItemSchemas = (subSchemas, key) => { + return subSchemas.map(function(sub) { + if (!sub) { + return; + } + + if (Array.isArray(sub.items)) { + var schemaAtPos = sub.items[key]; + if (isSchema(schemaAtPos)) { + return schemaAtPos; + } + else if (sub.hasOwnProperty('additionalItems')) { + return sub.additionalItems; + } + } + else { + return sub.items; + } + }); + }, + getValues = (schemas, key) => { + return schemas.map((schema) => { return schema && schema[key]; }); + }, + notUndefined = (val) => { return val !== undefined; }, + mergeSchemaGroup = (group, mergeSchemas, source) => { + var allKeys = allUniqueKeys(source || group), + extractor = source ? getItemSchemas : getValues; + + return allKeys.reduce(function(all, key) { + var schemas = extractor(group, key), + compacted = _.uniqWith(schemas.filter(notUndefined), _.compare); + + all[key] = mergeSchemas(compacted, key); + return all; + }, source ? [] : {}); + }, + removeFalseSchemas = (target) => { + forEach(target, function(schema, prop) { + if (schema === false) { + delete target[prop]; + } + }); + }; + + + // first get rid of all non permitted properties + if (!moduleOptions.ignoreAdditionalProperties) { + values.forEach(function(subSchema) { + var otherSubSchemas = values.filter((s) => { return s !== subSchema; }), + ownKeys = keys(subSchema.properties), + ownPatternKeys = keys(subSchema.patternProperties), + ownPatterns = ownPatternKeys.map((k) => { return new RegExp(k); }); + + otherSubSchemas.forEach(function(other) { + var allOtherKeys = keys(other.properties), + keysMatchingPattern = allOtherKeys.filter((k) => { + return ownPatterns.some((pk) => { return pk.test(k); }); + }), + additionalKeys = withoutArr(allOtherKeys, ownKeys, keysMatchingPattern); + + additionalKeys.forEach(function(key) { + other.properties[key] = mergers.properties([ + other.properties[key], subSchema.additionalProperties + ], key); + }); + }); + }); + + // remove disallowed patternProperties + values.forEach(function(subSchema) { + var otherSubSchemas = values.filter((s) => { return s !== subSchema; }), + ownPatternKeys = keys(subSchema.patternProperties); + if (subSchema.additionalProperties === false) { + otherSubSchemas.forEach(function(other) { + var allOtherPatterns = keys(other.patternProperties), + additionalPatternKeys = withoutArr(allOtherPatterns, ownPatternKeys); + additionalPatternKeys.forEach((key) => { delete other.patternProperties[key]; }); + }); + } + }); + } + + var returnObject = { + additionalProperties: mergers.additionalProperties(values.map((s) => { return s.additionalProperties; })), + patternProperties: mergeSchemaGroup(values.map((s) => { return s.patternProperties; }), + mergers.patternProperties), + properties: mergeSchemaGroup(values.map((s) => { return s.properties; }), mergers.properties) + }, + propsMap = {}, + requiredProps; + + if (returnObject.additionalProperties === false) { + removeFalseSchemas(returnObject.properties); + } + + // owned logic by this module starts from here till return + // count occurence of each props across all values + if (options.requireCommonProps) { + values.map((value) => { + if (typeof value.properties === 'object') { + let keys = Object.keys(value.properties); + keys.map((prop) => { + typeof propsMap[prop] === 'undefined' && (propsMap[prop] = 0); + propsMap[prop] += 1; + }); + } + }); + + // delete props which doesn't exist in all values + keys(propsMap).map((prop) => { + if (propsMap[prop] !== values.length) { + delete propsMap[prop]; + } + }); + requiredProps = Object.keys(propsMap); + // add required property to returned object + if (requiredProps.length && typeof returnObject.properties === 'object') { + returnObject.required = requiredProps; + } + } + + return returnObject; + } + } + }); + } + catch (e) { + console.warn('Pm2OasError: Error while merging JSON schemas', e.message); + // return callback(e); + return; + } + // return callback(null, mergedSchema); + return mergedSchema; +} + +module.exports = mergeJsonSchemas; diff --git a/lib/options.js b/lib/options.js index c0cc1b6f1..30fabc2b4 100644 --- a/lib/options.js +++ b/lib/options.js @@ -202,6 +202,43 @@ module.exports = { external: true, usage: ['VALIDATION'] }, + { + name: 'Require common properties', + id: 'requireCommonProps', + type: 'boolean', + default: false, + description: 'Whether to set common schema properties among multiple requests as required.', + external: true, + usage: ['SPEC_CONVERSION'] + }, + { + name: 'Output format for converted Specification', + id: 'outputFormat', + type: 'enum', + default: 'YAML', + availableOptions: ['YAML', 'JSON'], + description: 'Select whether to generate the output specification in YAML or the JSON format.', + external: true, + usage: ['SPEC_CONVERSION'] + }, + { + name: 'Include examples when available', + id: 'includeExamples', + type: 'boolean', + default: false, + description: 'Whether to include data present in request as OpenAPI example(s) object.', + external: true, + usage: ['SPEC_CONVERSION'] + }, + { + name: 'Extraction level for component extraction', + id: 'extractionLevels', + type: 'integer', + default: 2, + description: 'Choose how much deeper common component extraction happen in nested schemas', + external: true, + usage: ['SPEC_CONVERSION'] + }, { name: 'Disable optional parameters', id: 'disableOptionalParameters', diff --git a/lib/pm2oas.js b/lib/pm2oas.js new file mode 100644 index 000000000..6558c53ef --- /dev/null +++ b/lib/pm2oas.js @@ -0,0 +1,981 @@ +const _ = require('lodash'), + jsYaml = require('js-yaml'), + OpenApiErr = require('./error'), + convertToJsonSchema = require('./toJsonSchema'), + mergeJsonSchemas = require('./mergeJsonSchemas'), + extractCommonComponents = require('./extractCommonComponents'), + utils = require('./schemaUtils'), + ID_FORMATS = [ + { + schema: { type: 'string', format: 'uuid' }, + regex: /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/g + }, + { + schema: { type: 'integer' }, + regex: /^[-+]?\d+$/g + } + ], + EXCLUDED_HEADERS = [ + 'content-type', + 'accept', + 'authorization' + ], + JSON_CONTENT_TYPES = [ + 'application/json', + 'application/x-www-form-urlencoded', + 'multipart/form-dapp ata' + ], + DEFAULT_SPEC_NAME = 'OpenAPI Specification (Generated from Postman Collection)', + DEFAULT_SPEC_DESCRIPTION = 'This OpenAPI Specification was generated from Postman Collection using ' + + '[openapi-to-postman](https://github.com/postmanlabs/openapi-to-postman)'; + +/** + * Checks if path segment value is postman variable or not + * + * @param {*} value - Value to check for + * @returns {Boolean} postman variable or not + */ +function isPmVariable (value) { + // collection/environment variables are in format - {{var}} + return _.isString(value) && (_.startsWith(value, ':') || (_.startsWith(value, '{{') && _.endsWith(value, '}}'))); +} + +/** + * Fetches description in string format from Collection SDK defined description object + * + * @param {*} description - Collection SDK description + * @returns {*} - Actual description in string format or undefined if not present + */ +function getDescription (description) { + if (_.isString(description)) { + return description; + } + else if (_.isObject(description)) { + return description.content || undefined; + } + return undefined; +} + +/** + * Finds variable matches from request path. + * This variables can be path variables and generally understood id formats like uuid or integer. + * + * @param {Array} path - Path split into array + * @param {Array} pathVars - Path variables defined under "request.url.variables" + * @returns {Array} variable matches and some meta info regarding matches + */ +function findVariablesFromPath (path, pathVars) { + let varMatches = []; + _.forEach(path, (segment, index) => { + // find if path segment is pm variable (collection/env/path-var) + if (isPmVariable(segment)) { + let key = _.startsWith(segment, ':') ? segment.slice(1) : segment.slice(2, -2), + value = _.startsWith(segment, ':') ? _.find(pathVars, (pathVar) => { return pathVar.key === key; }).value : ''; + + varMatches.push({ key, value, index }); + } + // find if path segment is one of defined ID formats (e.g. UUID) + else { + // TODO: support path param serialization + _.forEach(ID_FORMATS, (format) => { + let match = format.regex.exec(segment), + key = index ? path[index - 1] + 'Id' : 'id' + index; + + if (match) { + varMatches.push({ + key, + value: segment, + index, + schema: format.schema + }); + // break once match found + return false; + } + }); + } + }); + return varMatches; +} + +/** + * Creates collection (array) from a requestGroup of given part. + * (e.g. path variables under all requests under same group) + * + * @param {Array} reqGroup - Request group + * @param {String} partPath - JSON path notation of property to be collected + * @returns {Array} - Collected parts + */ +function getAllPartFromReqGroup (reqGroup, partPath) { + let allReqPart = []; + + _.forEach(reqGroup, (req) => { + allReqPart = _.concat(allReqPart, _.get(req, partPath, [])); + }); + return allReqPart; +} + +/** + * Creates collection (array) of all request bodies present in requestGroup. + * + * @param {Array} reqGroup - Request group + * @returns {Array} - Collected request bodies + */ +function getAllReqBodies (reqGroup) { + let bodies = []; + + _.forEach(reqGroup, (req) => { + let body = _.get(req, 'request.request.body'); + + body && bodies.push(body); + }); + return bodies; +} + +/** + * Parses value provided to corresponding format. + * Used for parsing string/null value present in headers, query params or path variables + * + * @param {*} value - Value to be parsed + * @returns {*} - parsed value + */ +function parseParamValue (value) { + let parsedValue; + + // TODO: deserialization strategy + try { + parsedValue = JSON.parse(value); + } + catch (e) { + parsedValue = value; + } + return parsedValue; +} + +/** + * Transforms examples array to valid OAS examples object. + * transformed example object will have "exampleX" (X is number) as key and value as example. + * + * @param {Array} examplesArr - Array of OAS example objects + * @returns {Object} - Transformed examples object + */ +function transformToExamplesObject (examplesArr) { + // TODO: uniq examples with no duplicate data + let transformedExamples = {}; + _.forEach(examplesArr, (example, index) => { + transformedExamples['Example' + (index + 1)] = { + value: example + }; + }); + return transformedExamples; +} + +/** + * Generates OAS security object from on item/request auth + * + * @param {*} reqAuth - Auth defined under collection item/request + * @returns {*} - OAS securityn object + */ +function authHelper (reqAuth) { + let oasAuth; + + if (!_.isObject(reqAuth)) { + return null; + } + + switch (reqAuth.type) { + case 'apikey': + oasAuth = { type: 'apiKey' }; + + if (_.isArray(reqAuth.apikey)) { + let apiKeyLocation = _.find(reqAuth.apikey, (keyValPair) => { return keyValPair.key === 'in'; }), + apiKeyName = _.find(reqAuth.apikey, (keyValPair) => { return keyValPair.key === 'key'; }); + + apiKeyLocation && (oasAuth.in = apiKeyLocation.value); + apiKeyName && (oasAuth.name = apiKeyLocation.value); + } + break; + case 'basic': + case 'bearer': + case 'digest': + oasAuth = { type: 'http', scheme: reqAuth.type }; + break; + case 'oauth': + case 'oauth1': + oasAuth = { type: 'http', scheme: 'oauth' }; + break; + case 'oauth2': + oasAuth = { type: 'oauth2' }; + break; + default: + oasAuth = null; + break; + } + return oasAuth; +} + +/** + * Finds common path among all given paths + * + * @param {Array} paths - Paths (Array of strings) + * @returns {String} - common path + */ +function getCommonPath (paths) { + let commonPathLen; + + if (paths.length < 1) { + return ''; + } + if (paths.length < 2) { + return paths[0]; + } + + commonPathLen = _.min(_.map(paths, (path) => { return path.length; })); + + for (let i = 0; i < paths.length - 1; i++) { + let commonStringLen = 0; + + while (commonStringLen <= commonPathLen) { + if (paths[i].charAt(commonStringLen) === paths[i + 1].charAt(commonStringLen)) { + commonStringLen += 1; + } + else { + commonPathLen = commonStringLen; + break; + } + } + // at below point common path is only '/' + if (commonPathLen < 2) { + break; + } + } + + // remove ending `/` from match + if (paths[0].charAt(commonPathLen - 1) === '/') { + commonPathLen -= 1; + } + return paths[0].substr(0, commonPathLen); +} + +/** + * Defines global server from all given server (hosts) + * + * @param {Array} servers - Host url from all endpoints + * @returns {*} - global server + */ +function getGlobalServer (servers) { + let globalServer, + maxServerCount = 0, + serverCount = {}; + + _.forEach(servers, (server) => { + serverCount[server] ? serverCount[server] += 1 : serverCount[server] = 1; + if (serverCount[server] > maxServerCount) { + maxServerCount = serverCount[server]; + globalServer = server; + } + }); + return globalServer; +} + +/** + * Merges set of path variables. (all existing in same request group) + * merging is done based on index of path variable in path. (i.e. /user/1234 and /user/{userId} will be merged together) + * + * @param {Array} pathVars - Path variables + * @param {*} options - optionss + * @returns {Array} - merged path variables + */ +function mergePathVars (pathVars, options) { + let allPathVars = {}, + mergedPathVars; + + // traverse through all path variables and club value/schema with same key together + _.forEach(pathVars, (pathVar) => { + let parsedValue = parseParamValue(pathVar.value); + _.isEmpty(allPathVars[pathVar.index]) && + (allPathVars[pathVar.index] = { + key: pathVar.key, + description: getDescription(pathVar.description), + values: [], + examples: [] + }); + + !pathVar.schema && (allPathVars[pathVar.index].key = pathVar.key); + allPathVars[pathVar.index].values.push(convertToJsonSchema(parsedValue)); + !_.isEmpty(parsedValue) && allPathVars[pathVar.index].examples.push(parsedValue); + }); + + // merge clubbed schemas and generate openapi parameter objects for path vars + mergedPathVars = _.map(allPathVars, (pathVar, index) => { + let tempPathVar = { + index, + name: pathVar.key, + in: 'path', + schema: mergeJsonSchemas(pathVar.values, options), + required: true // path variables are always required + }; + + !_.isEmpty(pathVar.description) && (tempPathVar.description = pathVar.description); + + if (options.includeExamples) { + pathVar.examples = _.uniq(pathVar.examples); + if (pathVar.examples.length === 1) { + tempPathVar.example = pathVar.examples[0]; + } + else if (pathVar.examples.length > 1) { + tempPathVar.examples = transformToExamplesObject(pathVar.examples); + } + } + return tempPathVar; + }); + + return mergedPathVars; +} + +/** + * Merges set of query parameters. (all existing in same request group) + * merging is done based on name of query parameter. + * + * @param {Array} queries - Query parameters + * @param {*} options - options + * @returns {Array} - Merged Query Parameters + */ +function mergeQueries (queries, options) { + let allQueries = {}, + mergedQueries; + + // traverse through all queries and club value/schema with same key together + _.forEach(queries, (query) => { + let parsedValue = parseParamValue(query.value); + _.isEmpty(allQueries[query.key]) && + (allQueries[query.key] = { description: getDescription(query.description), values: [], examples: [] }); + + allQueries[query.key].values.push(convertToJsonSchema(parsedValue)); + allQueries[query.key].examples.push(parsedValue); + }); + + // merge clubbed schemas and generate openapi parameter objects for queries + // TODO: required queries (how to define ?) + mergedQueries = _.map(allQueries, (query, key) => { + let tempQuery = { + name: key, + in: 'query', + schema: mergeJsonSchemas(query.values, options) + }; + + !_.isEmpty(query.description) && (tempQuery.description = query.description); + + if (options.includeExamples) { + query.examples = _.uniq(query.examples); + if (query.examples.length === 1) { + tempQuery.example = query.examples[0]; + } + else if (query.examples.length > 1) { + tempQuery.examples = transformToExamplesObject(query.examples); + } + } + return tempQuery; + }); + + return mergedQueries; +} + +/** + * Merges set of Headers. (all existing in same request group) + * merging is done based on name of Header. + * + * @param {Array} headers - Headers + * @param {*} options - options + * @returns {Array} - Merged Headers + */ +function mergeHeaders (headers, options) { + let allHeaders = {}, + mergedHeaders; + + // traverse through all headers and club value/schema with same key together + _.forEach(headers, (header) => { + let parsedValue = parseParamValue(header.value); + + if (!_.includes(EXCLUDED_HEADERS, _.toLower(_.get(header, 'key')))) { + _.isEmpty(allHeaders[header.key]) && + (allHeaders[header.key] = { description: getDescription(header.description), values: [], examples: [] }); + allHeaders[header.key].values.push(convertToJsonSchema(parsedValue)); + allHeaders[header.key].examples.push(parsedValue); + } + }); + + // merge clubbed schemas and generate openapi parameter objects for headers + // TODO: required headers (how to define ?) + mergedHeaders = _.map(allHeaders, (header, key) => { + let tempHeader = { + name: key, + in: 'header', + schema: mergeJsonSchemas(header.values, options) + }; + + !_.isEmpty(header.description) && (tempHeader.description = header.description); + + if (options.includeExamples) { + header.examples = _.uniq(header.examples); + if (header.examples.length === 1) { + tempHeader.example = header.examples[0]; + } + else if (header.examples.length > 1) { + tempHeader.examples = transformToExamplesObject(header.examples); + } + } + return tempHeader; + }); + + return mergedHeaders; +} + +/** + * Merges set of request bodies. (all existing in same request group) + * merging is done based content type. (i.e. if two request both has application/json as + * content type then they will be merged otherwise both will be present as different content type) + * + * @param {Array} bodies - Request bodies + * @param {*} options - options + * @returns {Array} - Merged Request bodies + */ +function mergeReqBodies (bodies, options) { + // raw, urlencoded, formdata, file + let contentTypes = {}; + + _.forEach(bodies, (body) => { + let bodySchema, + dataContentType, + example; + + if (body.mode === 'file') { + dataContentType = 'application/octet-stream'; + bodySchema = { + type: 'string', + format: 'binary' + }; + } + else if (body.mode === 'formdata') { + dataContentType = 'multipart/form-data'; + bodySchema = { + type: 'object', + properties: {} + }; + example = {}; + + // TODO - support for disabled param + _.forEach(body.formdata, (ele) => { + let convertedSchema, + description = getDescription(ele.description); + + if (ele.src) { + convertedSchema = { type: 'string', format: 'binary' }; + } + else { + convertedSchema = convertToJsonSchema(ele.value); + } + bodySchema.properties[ele.key] = _.assign(convertedSchema, _.isEmpty(description) ? {} : { description }); + example[ele.key] = ele.value; + }); + } + else if (body.mode === 'urlencoded') { + dataContentType = 'application/x-www-form-urlencoded'; + bodySchema = { + type: 'object', + properties: {} + }; + example = {}; + + _.forEach(body.urlencoded, (ele) => { + let description = getDescription(ele.description); + + bodySchema.properties[ele.key] = _.assign(convertToJsonSchema(ele.value), + _.isEmpty(description) ? {} : { description }); + example[ele.key] = ele.value; + }); + } + else if (body.mode === 'raw') { + let rawData; + + try { + rawData = JSON.parse(body.raw || null); + dataContentType = 'application/json'; + example = rawData; + } + catch (e) { + // non Json value to be treated as string with apropriate content type + rawData = ''; + switch (_.get(body, 'options.raw.language')) { + case 'javascript': + dataContentType = 'application/javascript'; + break; + case 'html': + dataContentType = 'text/html'; + break; + case 'xml': + dataContentType = 'application/xml'; + break; + case 'text': + default: + dataContentType = 'text/plain'; + break; + } + } + bodySchema = convertToJsonSchema(rawData); + } + + // add generated body schema to corresponding content type + _.isEmpty(contentTypes[dataContentType]) && (contentTypes[dataContentType] = []); + contentTypes[dataContentType].push({ schema: bodySchema, example }); + }); + + // merge all body schemas under one content type + _.forEach(contentTypes, (allBodySchemas, contentType) => { + contentTypes[contentType] = { + schema: mergeJsonSchemas(_.map(allBodySchemas, 'schema'), options) + }; + + if (options.includeExamples) { + let examples = _.filter(_.map(allBodySchemas, 'example'), (example) => { return !_.isUndefined(example); }); + if (examples.length === 1) { + contentTypes[contentType].example = examples[0]; + } + else if (examples.length > 1) { + contentTypes[contentType].examples = transformToExamplesObject(examples); + } + } + }); + return contentTypes; +} + +/** + * Merges set of responses. (all existing in same request group) + * + * For Response body: + * merging is done based content type. (i.e. if two response body both has application/json as + * content type then they will be merged otherwise both will be present as different content type) + * + * For Response headers: + * merging is done based on name of Header. + * + * @param {Array} responses - Responses + * @param {*} options - options + * @returns {Array} - Merged responses + */ +function mergeResponses (responses, options) { + let responseGroups = {}, + mergedResponses = {}; + + _.forEach(responses, (response) => { + let responseCode = response.code || 'default'; + + _.isEmpty(responseGroups[responseCode]) && (responseGroups[responseCode] = []); + responseGroups[responseCode].push(response); + }); + + _.forEach(responseGroups, (responseGroup, responseCode) => { + let contentTypes = {}, + allHeaders = [], + mergedHeaders = {}, + responseDesc; + + _.forEach(responseGroup, (response) => { + let rawData, + bodySchema, + dataContentType, + example; + + try { + rawData = JSON.parse(response.body || null); + dataContentType = 'application/json'; + example = rawData; + } + catch (e) { + // non Json value to be treated as string with apropriate content type + rawData = ''; + switch (_.get(response, '_postman_previewlanguage', null)) { + case 'html': + dataContentType = 'text/html'; + break; + case 'xml': + dataContentType = 'application/xml'; + break; + case 'text': + default: + dataContentType = 'text/plain'; + break; + } + } + bodySchema = convertToJsonSchema(rawData); + + allHeaders = _.concat(allHeaders, _.get(response, 'header') || []); + + _.isEmpty(responseDesc) && (responseDesc = _.get(response, 'name')); + + // add generated body schema to corresponding content type + _.isEmpty(contentTypes[dataContentType]) && (contentTypes[dataContentType] = []); + contentTypes[dataContentType].push({ schema: bodySchema, example }); + }); + + _.forEach(mergeHeaders(allHeaders, options), (header) => { + mergedHeaders[header.name] = _.omit(header, ['name', 'in']); + }); + + // merge responses according to content types + _.forEach(contentTypes, (content, contentType) => { + let mergedBody = mergeJsonSchemas(_.map(content, 'schema'), options), + mergedContent = { + schema: mergedBody + }; + + if (options.includeExamples) { + let examples = _.filter(_.map(content, 'example'), (example) => { return !_.isUndefined(example); }); + if (examples.length === 1) { + mergedContent.example = examples[0]; + } + else if (examples.length > 1) { + mergedContent.examples = transformToExamplesObject(examples); + } + } + + _.set(mergedResponses, responseCode + '.content.' + contentType, mergedContent); + }); + + !_.isEmpty(responseDesc) && _.set(mergedResponses, responseCode + '.description', responseDesc); + !_.isEmpty(mergedHeaders) && _.set(mergedResponses, responseCode + '.headers', mergedHeaders); + }); + + return mergedResponses; +} + +/** + * Merges Postman Collection requests based on its URL + * + * @param {Array} requests - Array of Postman Collection Requests + * @param {Object} options - Options + * @param {Function} callback - callback function + * @returns {Array} - Merged collection requests + */ +function mergePmRequests (requests, options) { + let reqGroups = {}; + + // group requests based on URL path + _.forEach(requests, (request) => { + let urlPath = _.cloneDeep(request.request.url.path), + varMatches = findVariablesFromPath(urlPath, request.request.url.variable), + path; + + if (!_.isEmpty(varMatches)) { + _.forEach(varMatches, (varMatch) => { + urlPath[varMatch.index] = '*'; + }); + } + + path = _.get(request, 'request.method', 'GET') + ' ' + _.join(urlPath, '/'); + if (_.isEmpty(reqGroups[path])) { + reqGroups[path] = []; + } + reqGroups[path].push({ + request, + varMatches + }); + }); + + return _.map(reqGroups, (reqGroup, reqGroupPath) => { + let operation = {}, + mergedPath = reqGroupPath.slice(reqGroupPath.indexOf(' ') + 1).split('/'), + itemTreeInfo = _.get(reqGroup, '[0].request.itemTree'), // pick up first itemTree + reqHost = _.get(reqGroup, '[0].request.request.url.host'), + getItemProp = (itemTree, prop) => { + return _.get(_.findLast(itemTree, (itemInfo, index) => { + let propValue = _.get(itemInfo, prop); + + // don't count first item as it's collection info + return index && !_.isEmpty(propValue); + }), prop); + }; + + operation.method = reqGroupPath.slice(0, reqGroupPath.indexOf(' ')); + + // set first non-empty request name and description as operation summary and description + _.forEach(reqGroup, (req) => { + let reqName = _.get(req, 'request.name'), + reqDesc = _.get(req, 'request.request.description'); + + if (!_.isEmpty(reqName)) { operation.summary = reqName; } + + if (!_.isEmpty(reqDesc)) { operation.description = reqDesc; } + + if (operation.summary && operation.description) { return false; } + }); + + operation.pathVars = mergePathVars(getAllPartFromReqGroup(reqGroup, 'varMatches'), options); + operation.queries = mergeQueries(getAllPartFromReqGroup(reqGroup, 'request.request.url.query'), options); + operation.headers = mergeHeaders(getAllPartFromReqGroup(reqGroup, 'request.request.header'), options); + operation.requestBodies = mergeReqBodies(getAllReqBodies(reqGroup, 'request.request.body'), options); + operation.responses = mergeResponses(getAllPartFromReqGroup(reqGroup, 'request.response'), options); + + _.forEach(getAllPartFromReqGroup(reqGroup, 'request.request.auth'), (auth) => { + let securityObject = authHelper(auth); + + if (securityObject) { + operation.security = securityObject; + return false; + } + }); + + // look into item tree for auth definition if exists + if (!operation.security) { + operation.security = authHelper(getItemProp(itemTreeInfo, 'auth')); + } + + // update path with defined keys for identified path variables + _.forEach(operation.pathVars, (mergedPathVar) => { + mergedPath[mergedPathVar.index] = '{' + mergedPathVar.name + '}'; + _.unset(mergedPathVar, 'index'); + }); + + if (itemTreeInfo) { + let tag = getItemProp(itemTreeInfo, 'name'); + + if (!_.isEmpty(tag)) { + let tagDesc = getDescription(getItemProp(itemTreeInfo, 'description')); + + operation.tag = { name: tag }; + !_.isEmpty(tagDesc) && (operation.tag.description = tagDesc); + } + } + + operation.server = _.isArray(reqHost) ? _.join(reqHost, '.') : reqHost; + operation.path = '/' + mergedPath.join('/'); + operation.groupPath = reqGroupPath; + return operation; + }); +} + +/** + * Generates OpenAPI spec from set of requests. (as JSON (default) / YAML) + * Requests should be valid collection request. (Although "itemTree" property is handled) + * + * @param {Array} requests - Requests + * @param {*} options - options + * @returns {*} - Generated OAS in ndesiredd format + */ +async function generateOAS (requests, options) { + let mergedRequests = mergePmRequests(requests, options), + oasSpec = { + openapi: '3.0.3', + info: { + title: DEFAULT_SPEC_NAME, + version: '1.0.0' + }, + servers: [], + paths: {}, + components: {} + }, + securitySchemeCount = 1, + commonPath, + allSchemas = {}, + extractedSchemas = {}, + globalServer = getGlobalServer(_.map(mergedRequests, 'server')), + globalSecurityObj = authHelper(_.get(requests, '[0].itemTree[0].auth')); + + commonPath = getCommonPath(_.map(mergedRequests, 'path')); + oasSpec.servers.push({ url: globalServer + commonPath }); + oasSpec.info.title = _.get(requests, '[0].itemTree[0].name', DEFAULT_SPEC_NAME); + oasSpec.info.description = getDescription(_.get(requests, '[0].itemTree[0].description')) || DEFAULT_SPEC_DESCRIPTION; + + if (!_.isEmpty(globalSecurityObj)) { + let securitySchemeName = 'securityScheme' + securitySchemeCount; + + oasSpec.security = [{ [securitySchemeName]: [] }]; + _.set(oasSpec.components, 'securitySchemes.' + securitySchemeName, globalSecurityObj); + securitySchemeCount += 1; + } + + // collect all schemas from requests and response bodies and extract common components + _.forEach(mergedRequests, (mergedRequest) => { + let schemaName, + path = mergedRequest.path.substr(commonPath.length), + method = _.toLower(mergedRequest.method); + + _.isEmpty(path) && (path = '/'); + + if (!_.isEmpty(mergedRequest.requestBodies)) { + schemaName = utils.generateSchemaName(_.camelCase( + (_.isEmpty(mergedRequest.summary) ? `${method} ${path}` : mergedRequest.summary),), allSchemas); + _.forEach(_.get(mergedRequest.requestBodies, 'content'), (contentObj, contentType) => { + + if (_.includes(JSON_CONTENT_TYPES, contentType)) { + contentObj.schemaName = schemaName; + allSchemas[schemaName] = { + path: `["${path}"].${method}.requestBodies.content.${contentType}.schema`, + schema: contentObj.schema + }; + } + }); + } + + if (!_.isEmpty(mergedRequest.responses)) { + _.forEach(mergedRequest.responses, (response, responseCode) => { + let responseSchema = _.get(response, 'content.application/json.schema'); + + if (responseSchema) { + schemaName = utils.generateSchemaName(_.camelCase((_.isEmpty(mergedRequest.summary) ? `${method} ${path}` : + mergedRequest.summary) + responseCode + ' Response'), allSchemas); + + response.content['application/json'].schemaName = schemaName; + allSchemas[schemaName] = { + path: `["${path}"].${method}.responses.${responseCode}.content.application/json.schema`, + schema: responseSchema + }; + } + }); + } + }); + + // extract common components from all schemas + extractedSchemas = extractCommonComponents(_.mapValues(allSchemas, 'schema'), oasSpec.components, options); + + _.forEach(mergedRequests, (mergedRequest) => { + let operation = {}, + allParameters, + path = mergedRequest.path.substr(commonPath.length), + method = _.toLower(mergedRequest.method); + + allParameters = _.concat(mergedRequest.pathVars, mergedRequest.queries, mergedRequest.headers); + + if (mergedRequest.tag) { + let existingTag = _.find(oasSpec.tags, (tag) => { + return mergedRequest.tag.name === tag.name; + }); + + operation.tags = [mergedRequest.tag.name]; + + if (_.isEmpty(existingTag)) { + oasSpec.tags = _.concat(oasSpec.tags || [], mergedRequest.tag); + } + } + + _.assign(operation, _.pick(mergedRequest, ['summary', 'description', 'servers'])); + + !_.isEmpty(allParameters) && (operation.parameters = allParameters); + + if (!_.isEmpty(mergedRequest.requestBodies)) { + _.forEach(_.get(mergedRequest.requestBodies, 'content'), (contentObj, contentType) => { + if (contentObj.schemaName) { + _.set(mergedRequest, 'requestBodies.' + contentType + '.schema', extractedSchemas[contentObj.schemaName]); + _.unset(contentObj, 'schemaName'); + } + }); + operation.requestBody = { content: mergedRequest.requestBodies }; + } + + if (!_.isEmpty(mergedRequest.responses)) { + _.forEach(mergedRequest.responses, (response, responseCode) => { + let schemaName = _.get(response, 'content.application/json.schemaName'); + + if (schemaName) { + _.set(mergedRequest.responses, responseCode + '.content.application/json.schema', + extractedSchemas[schemaName]); + _.unset(response, 'content.application/json.schemaName'); + } + }); + operation.responses = mergedRequest.responses; + } + + // use default description to make sure openapi spec is valid + if (_.isEmpty(operation.responses)) { + operation.responses = { default: { description: 'No responses available' } }; + } + + if (mergedRequest.security) { + let securitySchemeName; + + if (!oasSpec.components.securitySchemes) { + oasSpec.components.securitySchemes = {}; + } + + // check if same security scheme exist or not + _.forEach(oasSpec.components.securitySchemes, (securityScheme, schemeName) => { + if (_.isEqual(securityScheme, mergedRequest.security)) { + securitySchemeName = schemeName; + } + }); + + // create security scheme if not present already + if (!securitySchemeName) { + securitySchemeName = 'securityScheme' + securitySchemeCount; + securitySchemeCount += 1; + _.set(oasSpec.components.securitySchemes, securitySchemeName, mergedRequest.security); + } + + operation.security = [{ [securitySchemeName]: [] }]; + } + + if (mergedRequest.server !== globalServer) { + operation.servers = [{ url: mergedRequest.server + commonPath }]; + } + + // keep root endpoint as path for empty paths + _.isEmpty(path) && (path = '/'); + + // escape . inside path with [""] + _.set(oasSpec.paths, `["${path}"].${method}`, operation); + }); + + if (options.outputFormat === 'yaml') { + try { + return jsYaml.dump(oasSpec, { + skipInvalid: true, // don't throw on invalid types and skip pairs and single values with such types. + noRefs: true // don't convert duplicate objects into references + }); + } + catch (e) { + throw OpenApiErr('Error while converting to YAML'); + } + } + return oasSpec; +} + +/** + * Flattens Postman collection v2 into requests while still pertaining item level info under "itemTree" prop + * + * @param {*} collection - Postman collection to flattened + * @param {Array} flattenCollection - Array of requests from flattened collection + * @param {*} folderInfoArr - All folders info till corresponding item + * @param {*} options - options + * @returns {*} - + */ +function flattenPmCollection (collection, flattenCollection, folderInfoArr, options) { + let folderInfo, + tempFolderInfoArr; + + if (!_.isArray(_.get(collection, 'item'))) { + return; + } + + folderInfo = _.pick((collection && collection.info) ? collection.info : collection, ['name', 'description']); + folderInfo.auth = _.get(collection, 'auth'); + folderInfo.variable = _.get(collection, 'variable'); + + tempFolderInfoArr = _.concat(folderInfoArr, folderInfo); + + _.forEach(collection.item, (item) => { + if (!_.isObject(item)) { + return; + } + + if (_.isArray(item.item)) { + flattenPmCollection(item, flattenCollection, tempFolderInfoArr, options); + } + else if (!_.isEmpty(item.request)) { + flattenCollection.push(_.assign({}, item, { itemTree: tempFolderInfoArr })); + } + }); +} + +module.exports = { + generateOAS, + flattenPmCollection +}; diff --git a/lib/schemaUtils.js b/lib/schemaUtils.js index 33332c118..d89d3c36f 100644 --- a/lib/schemaUtils.js +++ b/lib/schemaUtils.js @@ -1979,6 +1979,25 @@ module.exports = { return refObj; }, + /** + * Generates name for schema based on name, suffix is added if same name exists + * + * @param {String} initialName initial name + * @param {Object} schemas all existing schemas + * @returns {Strings} schema name + */ + generateSchemaName: function (initialName, schemas) { + let suffix = 1, + schemaName = initialName; + + // check whether name already exist + while (schemas[schemaName]) { + schemaName = initialName + suffix; + suffix += 1; + } + return schemaName; + }, + /** Finds all the possible path variables in a given path string * @param {string} path Path string : /pets/{petId} * @returns {array} Array of path variables. diff --git a/lib/schemapack.js b/lib/schemapack.js index c97581868..991050875 100644 --- a/lib/schemapack.js +++ b/lib/schemapack.js @@ -6,6 +6,7 @@ const COLLECTION_NAME = 'Imported from OpenAPI 3.0', async = require('async'), sdk = require('postman-collection'), schemaUtils = require('./schemaUtils.js'), + pm2oas = require('./pm2oas'), OasResolverOptions = { resolve: true, // Resolve external references jsonSchema: true // Treat $ref like JSON Schema and convert to OpenAPI Schema Objects @@ -29,6 +30,8 @@ class SchemaPack { this.input = input; this.validated = false; this.openapi = null; + this.collection = null; + this.requests = null; this.validationResult = null; this.definedOptions = getOptions(); this.computedOptions = null; @@ -60,7 +63,11 @@ class SchemaPack { reason: 'Input not provided' }; } - if (input.type === 'string' || input.type === 'json') { + + if (input.type === 'collection' || input.type === 'requests') { + return this.validatePmInput(); + } + else if (input.type === 'string' || input.type === 'json') { // no need for extra processing before calling the converter // string can be JSON or YAML json = input.data; @@ -89,7 +96,7 @@ class SchemaPack { // invalid input type this.validationResult = { result: false, - reason: `Invalid input type (${input.type}). type must be one of file/json/string.` + reason: `Invalid input type (${input.type}). type must be one of file/json/string/collection/requests.` }; return this.validationResult; } @@ -547,6 +554,92 @@ class SchemaPack { }, 0); } + validatePmInput () { + let type = this.input.type, + data = this.input.data; + + if (type === 'collection') { + if (!_.isObject(data)) { + this.validationResult = { + result: false, + reason: 'Input data for type (collection) should be an Object.' + }; + return this.validationResult; + } + else if (!_.isString(_.get(data, 'info.name')) || !_.isArray(_.get(data, 'item'))) { + this.validationResult = { + result: false, + reason: 'Input data for type (collection) should be valid collection.' + }; + return this.validationResult; + } + this.collection = data; + } + else if (type === 'requests') { + let invalidRequest; + + if (!_.isArray(data)) { + this.validationResult = { + result: false, + reason: 'Input data for type (requests) should be an Array.' + }; + return this.validationResult; + } + + _.forEach(data, (request, index) => { + if (_.isEmpty(_.get(request, 'request'))) { + invalidRequest = index; + return false; + } + }); + + if (invalidRequest) { + this.validationResult = { + result: false, + reason: `Input data for type (requests) is invalid. Invalid request at index [${invalidRequest}]` + }; + return this.validationResult; + } + this.requests = data; + } + this.validationResult = { result: true }; + this.validated = true; + } + + /** + * + * @description Converts Postman Collection v2 into OpenAPI spec + * + * @param {*} cb - return callback + * @returns {*} - converted OpenAPI spec in desired format + */ + async convertToSpec () { + let collection = this.collection, + options = this.computedOptions, + requests = this.requests ? this.requests : [], + convertedSpec; + + // flatten collection first + if (collection) { + pm2oas.flattenPmCollection(collection, requests, [], options); + } + + try { + convertedSpec = await pm2oas.generateOAS(requests, options); + } + catch (e) { + return e; + } + + return { + result: true, + output: [{ + type: 'openapi', + data: convertedSpec + }] + }; + } + static getOptions(mode, criteria) { return getOptions(mode, criteria); } diff --git a/lib/toJsonSchema.js b/lib/toJsonSchema.js new file mode 100644 index 000000000..30cdf89d1 --- /dev/null +++ b/lib/toJsonSchema.js @@ -0,0 +1,32 @@ +const toJsonSchema = require('to-json-schema'); + +/** + * Generates JSON schema from given data + * + * @param {*} data - Data to be converted into JSON schema + * @param {*} options - Options + * @returns {Object} - Corresponding JSON schema + */ +function convertToJsonSchema (data, options) { // eslint-disable-line no-unused-vars + let schema; + + try { + schema = toJsonSchema(data, { + // provide post processing function as option + postProcessFnc: (type, schema, value, defaultProcessFnc) => { + if (type === 'null') { + // for type:null avoid usiung null to follow OAS defined schema object + return { type: 'string', nullable: true }; + } + return defaultProcessFnc(type, schema, value); + } + }); + } + catch (e) { + console.warn('Pm2OasError: Error while converting to JSON schema', e.message); + return ''; + } + return schema; +} + +module.exports = convertToJsonSchema; diff --git a/package-lock.json b/package-lock.json index f31da7f85..df29c84b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1309,9 +1309,9 @@ "dev": true }, "js-yaml": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", - "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -1428,6 +1428,11 @@ "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -1438,11 +1443,36 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" }, + "lodash.keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-4.2.0.tgz", + "integrity": "sha1-oIYCrBLk+4P5H8H7ejYKTZujUgU=" + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, "lodash.mergewith": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" }, + "lodash.omit": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", + "integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA=" + }, + "lodash.without": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.without/-/lodash.without-4.4.0.tgz", + "integrity": "sha1-PNRXSgC2e643OpS3SHcmQFB7eqw=" + }, + "lodash.xor": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.xor/-/lodash.xor-4.5.0.tgz", + "integrity": "sha1-TUjtfpgJWwYyWCunFNP/iuj7HbY=" + }, "lru-cache": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", @@ -2638,6 +2668,19 @@ "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", "dev": true }, + "to-json-schema": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/to-json-schema/-/to-json-schema-0.2.5.tgz", + "integrity": "sha512-jP1ievOee8pec3tV9ncxLSS48Bnw7DIybgy112rhMCEhf3K4uyVNZZHr03iQQBzbV5v5Hos+dlZRRyk6YSMNDw==", + "requires": { + "lodash.isequal": "^4.5.0", + "lodash.keys": "^4.2.0", + "lodash.merge": "^4.6.2", + "lodash.omit": "^4.5.0", + "lodash.without": "^4.4.0", + "lodash.xor": "^4.5.0" + } + }, "tslib": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", diff --git a/package.json b/package.json index b482cca8a..191cfbe5b 100644 --- a/package.json +++ b/package.json @@ -119,12 +119,13 @@ "ajv": "6.12.3", "async": "3.2.0", "commander": "2.20.3", - "js-yaml": "3.13.1", + "js-yaml": "3.14.1", "json-schema-merge-allof": "0.7.0", "lodash": "4.17.21", "oas-resolver-browser": "2.5.1", "path-browserify": "1.0.1", "postman-collection": "3.6.6", + "to-json-schema": "0.2.5", "yaml": "1.8.3" }, "author": "Postman Labs ", diff --git a/test/system/structure.test.js b/test/system/structure.test.js index f798d08d1..2a3d69f70 100644 --- a/test/system/structure.test.js +++ b/test/system/structure.test.js @@ -20,6 +20,10 @@ const optionIds = [ 'ignoreUnresolvedVariables', 'optimizeConversion', 'strictRequestMatching', + 'requireCommonProps', + 'outputFormat', + 'includeExamples', + 'extractionLevels', 'disableOptionalParameters' ], expectedOptions = { @@ -141,6 +145,31 @@ const optionIds = [ description: 'Whether requests should be strictly matched with schema operations. Setting to true will not ' + 'include any matches where the URL path segments don\'t match exactly.' }, + requireCommonProps: { + name: 'Require common properties', + type: 'boolean', + default: false, + description: 'Whether to set common schema properties among multiple requests as required.' + }, + outputFormat: { + name: 'Output format for converted Specification', + type: 'enum', + default: 'YAML', + availableOptions: ['YAML', 'JSON'], + description: 'Select whether to generate the output specification in YAML or the JSON format.' + }, + includeExamples: { + name: 'Include examples when available', + type: 'boolean', + default: false, + description: 'Whether to include data present in request as OpenAPI example(s) object.' + }, + extractionLevels: { + name: 'Extraction level for component extraction', + type: 'integer', + default: 2, + description: 'Choose how much deeper common component extraction happen in nested schemas' + }, disableOptionalParameters: { name: 'Disable optional parameters', type: 'boolean', diff --git a/test/unit/base.test.js b/test/unit/base.test.js index a66e12772..d8341f0f1 100644 --- a/test/unit/base.test.js +++ b/test/unit/base.test.js @@ -1048,7 +1048,8 @@ describe('INTERFACE FUNCTION TESTS ', function () { expect(result.reason).to.contain('input'); Converter.convert({ type: 'fil', data: 'invalid_path' }, {}, function(err, conversionResult) { expect(conversionResult.result).to.equal(false); - expect(conversionResult.reason).to.equal('Invalid input type (fil). type must be one of file/json/string.'); + expect(conversionResult.reason).to.equal('Invalid input type (fil). ' + + 'type must be one of file/json/string/collection/requests.'); done(); }); });