diff --git a/CHANGELOG.md b/CHANGELOG.md index c81b6f38..8b8a8236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ ## [Unreleased] +## [v4.24.0] - 2024-08-13 + +### Added + +- [#98](https://github.com/postmanlabs/openapi-to-postman/issues/98) [12255](https://github.com/postmanlabs/postman-app-support/issues/12255) Added support for readOnly and writeOnly properties to be correctly present in generated collection. + +### Chore + +- Replaced traverse with neotraverse. + +## [v4.23.1] - 2024-07-22 + +### Added + +- Conversion - Added option to set preferred request body content-type and use the first mentioned content-type as request body. + +### Fixed + +- Fixed issue with getOptions() API where default module version was still v1. +- Fix to convert "format:binary" to "type:file" for requests with formdata body. + +## [v4.22.0] - 2024-07-10 + +### Chore + +- Updated postman-collection to v4.4.0. + +## [v4.21.0] - 2024-05-17 + +### Added + +- Added support for simplified request and response body matching in case of multiple examples. + +## [v4.20.1] - 2024-03-27 + +### Fixed + +- Fixed an issue where schemas under allOf keyword having additionalProperties set to false were not generating bodies correctly. + ## [v4.20.0] - 2024-02-15 ### Added @@ -608,7 +647,17 @@ Newer releases follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0 - Base release -[Unreleased]: https://github.com/postmanlabs/openapi-to-postman/compare/v4.20.0...HEAD +[Unreleased]: https://github.com/postmanlabs/openapi-to-postman/compare/v4.24.0...HEAD + +[v4.24.0]: https://github.com/postmanlabs/openapi-to-postman/compare/v4.23.1...v4.24.0 + +[v4.23.1]: https://github.com/postmanlabs/openapi-to-postman/compare/v4.22.0...v4.23.1 + +[v4.22.0]: https://github.com/postmanlabs/openapi-to-postman/compare/v4.21.0...v4.22.0 + +[v4.21.0]: https://github.com/postmanlabs/openapi-to-postman/compare/v4.20.1...v4.21.0 + +[v4.20.1]: https://github.com/postmanlabs/openapi-to-postman/compare/v4.20.0...v4.20.1 [v4.20.0]: https://github.com/postmanlabs/openapi-to-postman/compare/v4.19.0...v4.20.0 diff --git a/OPTIONS.md b/OPTIONS.md index 6b70acac..1f956554 100644 --- a/OPTIONS.md +++ b/OPTIONS.md @@ -6,8 +6,11 @@ collapseFolders|boolean|-|true|Importing will collapse all folders that have onl optimizeConversion|boolean|-|true|Optimizes conversion for large specification, disabling this option might affect the performance of conversion.|CONVERSION|v1 requestParametersResolution|enum|Example, Schema|Schema|Select whether to generate the request parameters based on the [schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject) or the [example](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#exampleObject) in the schema.|CONVERSION|v1 exampleParametersResolution|enum|Example, Schema|Example|Select whether to generate the response parameters based on the [schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject) or the [example](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#exampleObject) in the schema.|CONVERSION|v1 +disabledParametersValidation|boolean|-|true|Whether disabled parameters of collection should be validated|VALIDATION|v2, v1 parametersResolution|enum|Example, Schema|Schema|Select whether to generate the request and response parameters based on the [schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject) or the [example](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#exampleObject) in the schema.|CONVERSION|v2, v1 folderStrategy|enum|Paths, Tags|Paths|Select whether to create folders according to the spec’s paths or tags.|CONVERSION|v2, v1 +schemaFaker|boolean|-|true|Whether or not schemas should be faked.|CONVERSION|v2, v1 +stackLimit|integer|-|10|Number of nesting limit till which schema resolution will happen. Increasing this limit may result in more time to convert collection depending on complexity of specification. (To make sure this option works correctly "optimizeConversion" option needs to be disabled)|CONVERSION|v2, v1 includeAuthInfoInExample|boolean|-|true|Select whether to include authentication parameters in the example request.|CONVERSION|v2, v1 shortValidationErrors|boolean|-|false|Whether detailed error messages are required for request <> schema validation operations.|VALIDATION|v2, v1 validationPropertiesToIgnore|array|-|[]|Specific properties (parts of a request/response pair) to ignore during validation. Must be sent as an array of strings. Valid inputs in the array: PATHVARIABLE, QUERYPARAM, HEADER, BODY, RESPONSE_HEADER, RESPONSE_BODY|VALIDATION|v2, v1 @@ -20,5 +23,8 @@ strictRequestMatching|boolean|-|false|Whether requests should be strictly matche allowUrlPathVarMatching|boolean|-|false|Whether to allow matching path variables that are available as part of URL itself in the collection request|VALIDATION|v2, v1 enableOptionalParameters|boolean|-|true|Optional parameters aren't selected in the collection. Once enabled they will be selected in the collection and request as well.|CONVERSION|v2, v1 keepImplicitHeaders|boolean|-|false|Whether to keep implicit headers from the OpenAPI specification, which are removed by default.|CONVERSION|v2, v1 +includeWebhooks|boolean|-|false|Select whether to include Webhooks in the generated collection|CONVERSION|v2, v1 +includeReferenceMap|boolean|-|false|Whether or not to include reference map or not as part of output|BUNDLE|v2, v1 includeDeprecated|boolean|-|true|Select whether to include deprecated operations, parameters, and properties in generated collection or not|CONVERSION, VALIDATION|v2, v1 alwaysInheritAuthentication|boolean|-|false|Whether authentication details should be included on every request, or always inherited from the collection.|CONVERSION|v2, v1 +preferredRequestBodyType|enum|x-www-form-urlencoded, form-data, raw, first-listed|first-listed|When there are multiple content-types defined in the request body of OpenAPI, the conversion selects the preferred option content-type as request body.If "first-listed" is set, the first content-type defined in the OpenAPI spec will be selected.|CONVERSION|v2 diff --git a/lib/bundle.js b/lib/bundle.js index e97cc190..6a4cdf08 100644 --- a/lib/bundle.js +++ b/lib/bundle.js @@ -14,7 +14,7 @@ const _ = require('lodash'), jsonPointerDecodeAndReplace, generateObjectName } = require('./jsonPointer'), - traverseUtility = require('traverse'), + traverseUtility = require('neotraverse/legacy'), parse = require('./parse.js'), { ParseError } = require('./common/ParseError'), Utils = require('./utils'), diff --git a/lib/common/versionUtils.js b/lib/common/versionUtils.js index 7ece46ac..cf90fd04 100644 --- a/lib/common/versionUtils.js +++ b/lib/common/versionUtils.js @@ -212,9 +212,10 @@ function getSpecVersion({ type, data, specificationVersion }) { const openapi30 = getVersionRegexp(VERSION_30), openapi31 = getVersionRegexp(VERSION_31), openapi20 = getVersionRegexp(VERSION_20), - is30 = data.match(openapi30), - is31 = data.match(openapi31), - is20 = data.match(openapi20); + is30 = typeof data === 'string' && data.match(openapi30), + is31 = typeof data === 'string' && data.match(openapi31), + is20 = typeof data === 'string' && data.match(openapi20); + let version = DEFAULT_SPEC_VERSION; if (is30) { diff --git a/lib/deref.js b/lib/deref.js index 7bbcabbd..98cee435 100644 --- a/lib/deref.js +++ b/lib/deref.js @@ -35,7 +35,7 @@ const _ = require('lodash'), isAllOf: false }, DEFAULT_SCHEMA_UTILS = require('./30XUtils/schemaUtils30X'), - traverseUtility = require('traverse'), + traverseUtility = require('neotraverse/legacy'), PROPERTIES_TO_ASSIGN_ON_CASCADE = ['type', 'nullable']; /** @@ -131,6 +131,8 @@ module.exports = { { stack, seenRef: _.cloneDeep(seenRef), resolveFor, resolveTo, stackLimit, isAllOf: true, analytics }); }) }), { + // below option is required to make sure schemas with additionalProperties set to false are resolved correctly + ignoreAdditionalProperties: true, resolvers: { // for keywords in OpenAPI schema that are not standard defined JSON schema keywords, use default resolver defaultResolver: (compacted) => { return compacted[0]; }, diff --git a/lib/options.js b/lib/options.js index a75da5bc..e3cdc2c5 100644 --- a/lib/options.js +++ b/lib/options.js @@ -47,10 +47,12 @@ module.exports = { * * @param {string} [mode='document'] Describes use-case. 'document' will return an array * with all options being described. 'use' will return the default values of all options - * @param {Object} criteria Decribes required criteria for options to be returned. can have properties - * external: - * usage: (Array of supported usage type - CONVERSION, VALIDATION) - * version: ('3.0' by default, supported values: '3.0', '3.1') + * @param {Object} criteria Decribes required criteria for options to be returned. + * @param {string} criteria.version The version of the OpenAPI spec to be converted + * (can be one of '2.0', '3.0', '3.1') + * @param {string} criteria.moduleVersion The version of the module (can be one of 'v1' or 'v2') + * @param {Array} criteria.usage The usage of the option (values can be one of 'CONVERSION', 'VALIDATION') + * @param {boolean} criteria.external Whether the option is exposed to Postman App UI or not * @returns {mixed} An array or object (depending on mode) that describes available options */ getOptions: function(mode = 'document', criteria = {}) { @@ -386,6 +388,20 @@ module.exports = { usage: ['CONVERSION'], supportedIn: [VERSION20, VERSION30, VERSION31], supportedModuleVersion: [MODULE_VERSION.V2, MODULE_VERSION.V1] + }, + { + name: 'Select request body type', + id: 'preferredRequestBodyType', + type: 'enum', + default: 'first-listed', + availableOptions: ['x-www-form-urlencoded', 'form-data', 'raw', 'first-listed'], + description: 'When there are multiple content-types defined in the request body of OpenAPI, the conversion ' + + 'selects the preferred option content-type as request body.If "first-listed" is set, the first ' + + 'content-type defined in the OpenAPI spec will be selected.', + external: false, + usage: ['CONVERSION'], + supportedIn: [VERSION20, VERSION30, VERSION31], + supportedModuleVersion: [MODULE_VERSION.V2] } ]; @@ -408,7 +424,7 @@ module.exports = { } // Setting default value - criteria.moduleVersion = _.has(criteria, 'moduleVersion') ? criteria.moduleVersion : MODULE_VERSION.V1; + criteria.moduleVersion = _.has(criteria, 'moduleVersion') ? criteria.moduleVersion : MODULE_VERSION.V2; if (!_.includes(option.supportedModuleVersion, criteria.moduleVersion)) { return false; diff --git a/lib/relatedFiles.js b/lib/relatedFiles.js index 08563731..0d40c21b 100644 --- a/lib/relatedFiles.js +++ b/lib/relatedFiles.js @@ -1,5 +1,5 @@ const parse = require('./parse.js'), - traverseUtility = require('traverse'), + traverseUtility = require('neotraverse/legacy'), BROWSER = 'browser', { DFS } = require('./dfs'), { isExtRef, removeLocalReferenceFromPath } = require('./jsonPointer'); diff --git a/lib/schemaUtils.js b/lib/schemaUtils.js index 5eb5e704..2a6f7ad0 100644 --- a/lib/schemaUtils.js +++ b/lib/schemaUtils.js @@ -2,13 +2,18 @@ * This file contains util functions that need OAS-awareness * utils.js contains other util functions */ - -const { ParseError } = require('./common/ParseError.js'); - const { formatDataPath, checkIsCorrectType, isKnownType } = require('./common/schemaUtilsCommon.js'), { getConcreteSchemaUtils, isSwagger, validateSupportedVersion } = require('./common/versionUtils.js'), async = require('async'), - sdk = require('postman-collection'), + { Variable } = require('postman-collection/lib/collection/variable'), + { QueryParam } = require('postman-collection/lib/collection/query-param'), + { Header } = require('postman-collection/lib/collection/header'), + { ItemGroup } = require('postman-collection/lib/collection/item-group'), + { Item } = require('postman-collection/lib/collection/item'), + { FormParam } = require('postman-collection/lib/collection/form-param'), + { RequestAuth } = require('postman-collection/lib/collection/request-auth'), + { Response } = require('postman-collection/lib/collection/response'), + { RequestBody } = require('postman-collection/lib/collection/request-body'), schemaFaker = require('../assets/json-schema-faker.js'), deref = require('./deref.js'), _ = require('lodash'), @@ -19,7 +24,8 @@ const { formatDataPath, checkIsCorrectType, isKnownType } = require('./common/sc { Node, Trie } = require('./trie.js'), { validateSchema } = require('./ajValidation/ajvValidation'), inputValidation = require('./30XUtils/inputValidation'), - traverseUtility = require('traverse'), + traverseUtility = require('neotraverse/legacy'), + { ParseError } = require('./common/ParseError.js'), SCHEMA_FORMATS = { DEFAULT: 'default', // used for non-request-body data and json XML: 'xml' // used for request-body XMLs @@ -511,7 +517,7 @@ module.exports = { if (serverVariables) { _.forOwn(serverVariables, (value, key) => { let description = this.getParameterDescription(value); - variables.push(new sdk.Variable({ + variables.push(new Variable({ key: key, value: value.default || '', description: description @@ -519,7 +525,7 @@ module.exports = { }); } if (keyName) { - variables.push(new sdk.Variable({ + variables.push(new Variable({ key: keyName, value: serverUrl, type: 'string' @@ -739,7 +745,7 @@ module.exports = { addCollectionItemsFromWebhooks: function(spec, generatedStore, components, options, schemaCache) { let webhooksObj = this.generateTrieFromPaths(spec, options, true), webhooksTree = webhooksObj.tree, - webhooksFolder = new sdk.ItemGroup({ name: 'Webhooks' }), + webhooksFolder = new ItemGroup({ name: 'Webhooks' }), variableStore = {}, webhooksVariables = []; @@ -752,7 +758,7 @@ module.exports = { webhooksTree.root.children.hasOwnProperty(child) && webhooksTree.root.children[child].requestCount > 0 ) { - webhooksVariables.push(new sdk.Variable({ + webhooksVariables.push(new Variable({ key: this.cleanWebhookName(child), value: '/', type: 'string' @@ -838,7 +844,7 @@ module.exports = { // variableStore contains all the kinds of variable created. // Add only the variables with type 'collection' to generatedStore.collection.variables if (variableStore[key].type === 'collection') { - const collectionVar = new sdk.Variable(variableStore[key]); + const collectionVar = new Variable(variableStore[key]); generatedStore.collection.variables.add(collectionVar); } } @@ -961,7 +967,7 @@ module.exports = { // Add all folders created from tags and corresponding operations // Iterate from bottom to top order to maintain tag order in spec _.forEachRight(tagFolders, (tagFolder, tagName) => { - var itemGroup = new sdk.ItemGroup({ + var itemGroup = new ItemGroup({ name: tagName, description: tagFolder.description }); @@ -981,7 +987,7 @@ module.exports = { // Add only the variables with type 'collection' to generatedStore.collection.variables _.forEach(variableStore, (variable) => { if (variable.type === 'collection') { - const collectionVar = new sdk.Variable(variable); + const collectionVar = new Variable(variable); generatedStore.collection.variables.add(collectionVar); } }); @@ -997,7 +1003,7 @@ module.exports = { * resolve references while generating params. * @param {object} options - a standard list of options that's globally passed around. Check options.js for more. * @param {object} schemaCache - object storing schemaFaker and schmeResolution caches - * @returns {Array} returns an array of sdk.Variable + * @returns {Array} returns an array of Collection SDK Variable */ convertPathVariables: function(type, providedPathVars, commonPathVars, components, options, schemaCache) { var variables = []; @@ -1067,7 +1073,7 @@ module.exports = { if (resource.requestCount > 1) { // only return a Postman folder if this folder has>1 children in its subtree // otherwise we can end up with 10 levels of folders with 1 request in the end - itemGroup = new sdk.ItemGroup({ + itemGroup = new ItemGroup({ name: resource.name // TODO: have to add auth here (but first, auth to be put into the openapi tree) }); @@ -1308,9 +1314,9 @@ module.exports = { */ generateSdkParam: function (param, location) { const sdkElementMap = { - 'query': sdk.QueryParam, - 'header': sdk.Header, - 'path': sdk.Variable + 'query': QueryParam, + 'header': Header, + 'path': Variable }; let generatedParam = { @@ -1913,7 +1919,7 @@ module.exports = { convertedHeader = _.get(this.convertParamsWithStyle(header, fakeData, parameterSource, components, schemaCache, options), '[0]'); - reqHeader = new sdk.Header(convertedHeader); + reqHeader = new Header(convertedHeader); reqHeader.description = this.getParameterDescription(header); return reqHeader; @@ -1936,7 +1942,7 @@ module.exports = { originalParam, paramArray = [], updateOptions = {}, - reqBody = new sdk.RequestBody(), + reqBody = new RequestBody(), contentHeader, contentTypes = {}, rDataMode, @@ -2021,7 +2027,7 @@ module.exports = { }; // add a content type header for each media type for the request body - contentHeader = new sdk.Header({ + contentHeader = new Header({ key: 'Content-Type', value: URLENCODED }); @@ -2088,14 +2094,14 @@ module.exports = { originalParam.type === 'string' && originalParam.format === 'binary' ) { - param = new sdk.FormParam({ + param = new FormParam({ key: key, value: '', type: 'file' }); } else { - param = new sdk.FormParam({ + param = new FormParam({ key: key, value: value, type: 'text' @@ -2112,7 +2118,7 @@ module.exports = { formdata: paramArray }; // add a content type header for the pertaining media type - contentHeader = new sdk.Header({ + contentHeader = new Header({ key: 'Content-Type', value: FORM_DATA }); @@ -2177,7 +2183,7 @@ module.exports = { }; } - contentHeader = new sdk.Header({ + contentHeader = new Header({ key: 'Content-Type', value: bodyType }); @@ -2246,7 +2252,7 @@ module.exports = { responseMediaTypes = _.keys(response.content); if (responseMediaTypes.length > 0) { - let acceptHeader = new sdk.Header({ + let acceptHeader = new Header({ key: 'Accept', value: responseMediaTypes[0] }); @@ -2256,7 +2262,7 @@ module.exports = { } } - sdkResponse = new sdk.Response({ + sdkResponse = new Response({ name: response.description, code: code || 500, header: responseHeaders, @@ -2654,7 +2660,7 @@ module.exports = { } // creating the request object - item = new sdk.Item({ + item = new Item({ name: reqName, request: { description: operation.description, @@ -2672,7 +2678,7 @@ module.exports = { }; thisAuthObject[authMap[authMeta.currentHelper]] = authMeta.helperAttributes; - item.request.auth = new sdk.RequestAuth(thisAuthObject); + item.request.auth = new RequestAuth(thisAuthObject); } else { item.request.auth = authHelper; diff --git a/lib/schemapack.js b/lib/schemapack.js index 07bc8a50..e3bd8d17 100644 --- a/lib/schemapack.js +++ b/lib/schemapack.js @@ -6,7 +6,8 @@ const { getConcreteSchemaUtils } = require('./common/versionUtils.js'), Ajv = require('ajv'), addFormats = require('ajv-formats'), async = require('async'), - sdk = require('postman-collection'), + { Collection } = require('postman-collection/lib/collection/collection'), + { Url } = require('postman-collection/lib/collection/url'), OasResolverOptions = { resolve: true, // Resolve external references jsonSchema: true // Treat $ref like JSON Schema and convert to OpenAPI Schema Objects @@ -340,7 +341,7 @@ class SchemaPack { // Creating a new instance of a Postman collection // All generated folders and requests will go inside this - generatedStore.collection = new sdk.Collection({ + generatedStore.collection = new Collection({ info: { name: utils.getCollectionName(_.get(openapi, 'info.title')) } @@ -530,7 +531,7 @@ class SchemaPack { }); // SDK URL object. Get raw string representation. - requestUrl = (new sdk.Url(requestUrl)).toString(); + requestUrl = (new Url(requestUrl)).toString(); } // 1. Look at transaction.request.URL + method, and find matching request from schema diff --git a/libV2/index.js b/libV2/index.js index b881d810..1c3ce5af 100644 --- a/libV2/index.js +++ b/libV2/index.js @@ -1,6 +1,6 @@ /* eslint-disable one-var */ const _ = require('lodash'), - sdk = require('postman-collection'), + { Collection } = require('postman-collection/lib/collection/collection'), GraphLib = require('graphlib'), generateSkeletonTreeFromOpenAPI = require('./helpers/collection/generateSkeletionTreeFromOpenAPI'), generateCollectionFromOpenAPI = require('./helpers/collection/generateCollectionFromOpenAPI'), @@ -45,7 +45,7 @@ module.exports = { case 'collection': { // dummy collection to be generated. const { data, variables } = generateCollectionFromOpenAPI(context, node); - collection = new sdk.Collection(data); + collection = new Collection(data); collection = collection.toJSON(); @@ -213,6 +213,11 @@ module.exports = { } }); + // Remove duplicate variables as different requests could end up creating same variables + if (!_.isEmpty(collection.variable)) { + collection.variable = _.uniqBy(collection.variable, 'key'); + } + return cb(null, { result: true, output: [{ diff --git a/libV2/schemaUtils.js b/libV2/schemaUtils.js index 1e3be334..77ba2f54 100644 --- a/libV2/schemaUtils.js +++ b/libV2/schemaUtils.js @@ -45,6 +45,7 @@ const schemaFaker = require('../assets/json-schema-faker'), 'ipv4', 'ipv6', 'regex', 'uuid', + 'binary', 'json-pointer', 'int64', 'float', @@ -252,6 +253,36 @@ let QUERYPARAM = 'query', return REF_STACK_LIMIT; }, + /** + * Resets cache storing readOnly and writeOnly property map. + * + * @param {Object} context - Global context object + * @returns {void} + */ + resetReadWritePropCache = (context) => { + context.readOnlyPropCache = {}; + context.writeOnlyPropCache = {}; + }, + + /** + * Merges provided readOnly writeOnly properties cache with existing cache present in context + * + * @param {Object} context - Global context object + * @param {Object} readOnlyPropCache - readOnly properties cache to be merged + * @param {Object} writeOnlyPropCache - writeOnly properties cache to be merged + * @param {Object} currentPath - Current path (json-pointer) being resolved relative to original schema + * @returns {void} + */ + mergeReadWritePropCache = (context, readOnlyPropCache, writeOnlyPropCache, currentPath = '') => { + _.forOwn(readOnlyPropCache, (value, key) => { + context.readOnlyPropCache[utils.mergeJsonPath(currentPath, key)] = true; + }); + + _.forOwn(writeOnlyPropCache, (value, key) => { + context.writeOnlyPropCache[utils.mergeJsonPath(currentPath, key)] = true; + }); + }, + /** * Resolve a given ref from the schema * @param {Object} context - Global context object @@ -259,7 +290,7 @@ let QUERYPARAM = 'query', * @param {Number} stackDepth - Depth of the current stack for Ref resolution * @param {Object} seenRef - Seen Reference map * - * @returns {Object} Returns the object that staisfies the schema + * @returns {Object} Returns the object that satisfies the schema */ resolveRefFromSchema = (context, $ref, stackDepth = 0, seenRef = {}) => { const { specComponents } = context, @@ -273,7 +304,11 @@ let QUERYPARAM = 'query', seenRef[$ref] = true; if (context.schemaCache[$ref]) { - return context.schemaCache[$ref]; + // Also merge readOnly and writeOnly prop cache from schemaCache to global context cache + mergeReadWritePropCache(context, context.schemaCache[$ref].readOnlyPropCache, + context.schemaCache[$ref].writeOnlyPropCache); + + return context.schemaCache[$ref].schema; } if (!_.isFunction($ref.split)) { @@ -329,7 +364,7 @@ let QUERYPARAM = 'query', * @param {Number} stackDepth - Depth of the current stack for Ref resolution * @param {Object} seenRef - Seen Reference map * - * @returns {Object} Returns the object that staisfies the schema + * @returns {Object} Returns the object that satisfies the schema */ resolveRefForExamples = (context, $ref, stackDepth = 0, seenRef = {}) => { const { specComponents } = context, @@ -343,7 +378,11 @@ let QUERYPARAM = 'query', seenRef[$ref] = true; if (context.schemaCache[$ref]) { - return context.schemaCache[$ref]; + // Also merge readOnly and writeOnly prop cache from schemaCache to global context cache + mergeReadWritePropCache(context, context.schemaCache[$ref].readOnlyPropCache, + context.schemaCache[$ref].writeOnlyPropCache); + + return context.schemaCache[$ref].schema; } if (!_.isFunction($ref.split)) { @@ -390,7 +429,11 @@ let QUERYPARAM = 'query', } // Add the resolved schema to the global schema cache - context.schemaCache[$ref] = resolvedExample; + context.schemaCache[$ref] = { + schema: resolvedExample, + readOnlyPropCache: {}, + writeOnlyPropCache: {} + }; return resolvedExample; }, @@ -426,7 +469,7 @@ let QUERYPARAM = 'query', exampleKey = Object.keys(exampleObj)[0]; example = exampleObj[exampleKey]; - if (example.$ref) { + if (example && example.$ref) { example = resolveExampleData(context, example); } @@ -438,24 +481,27 @@ let QUERYPARAM = 'query', }, /** - * Handle resoltion of allOf property of schema + * Handle resolution of allOf property of schema * * @param {Object} context - Global context object * @param {Object} schema - Schema to be resolved * @param {Number} [stack] - Current recursion depth * @param {*} resolveFor - resolve refs for flow validation/conversion (value to be one of VALIDATION/CONVERSION) * @param {Object} seenRef - Map of all the references that have been resolved + * @param {String} currentPath - Current path (json-pointer) being resolved relative to original schema * * @returns {Object} Resolved schema */ - resolveAllOfSchema = (context, schema, stack, resolveFor = CONVERSION, seenRef = {}) => { + resolveAllOfSchema = (context, schema, stack = 0, resolveFor = CONVERSION, seenRef = {}, currentPath = '') => { try { return mergeAllOf(_.assign(schema, { allOf: _.map(schema.allOf, (schema) => { // eslint-disable-next-line no-use-before-define - return resolveSchema(context, schema, stack, resolveFor, _.cloneDeep(seenRef)); + return _resolveSchema(context, schema, stack, resolveFor, _.cloneDeep(seenRef), currentPath); }) }), { + // below option is required to make sure schemas with additionalProperties set to false are resolved correctly + ignoreAdditionalProperties: true, resolvers: { // for keywords in OpenAPI schema that are not standard defined JSON schema keywords, use default resolver defaultResolver: (compacted) => { return compacted[0]; }, @@ -477,13 +523,14 @@ let QUERYPARAM = 'query', * @param {Object} context - Global context * @param {Object} schema - Schema that is to be resolved * @param {Number} [stack] - Current recursion depth - * @param {String} resolveFor - For which action this resoltion is to be done + * @param {String} resolveFor - For which action this resolution is to be done * @param {Object} seenRef - Map of all the references that have been resolved + * @param {String} currentPath - Current path (json-pointer) being resolved relative to original schema * @todo: Explore using a directed graph/tree for maintaining seen ref * - * @returns {Object} Returns the object that staisfies the schema + * @returns {Object} Returns the object that satisfies the schema */ - resolveSchema = (context, schema, stack = 0, resolveFor = CONVERSION, seenRef = {}) => { + _resolveSchema = (context, schema, stack = 0, resolveFor = CONVERSION, seenRef = {}, currentPath = '') => { if (!schema) { return new Error('Schema is empty'); } @@ -520,16 +567,17 @@ let QUERYPARAM = 'query', }); if (resolveFor === CONVERSION) { - return resolveSchema(context, compositeSchema[0], stack, resolveFor, _.cloneDeep(seenRef)); + return _resolveSchema(context, compositeSchema[0], stack, resolveFor, _.cloneDeep(seenRef), currentPath); } - return { [compositeKeyword]: _.map(compositeSchema, (schemaElement) => { - return resolveSchema(context, schemaElement, stack, resolveFor, _.cloneDeep(seenRef)); + return { [compositeKeyword]: _.map(compositeSchema, (schemaElement, index) => { + return _resolveSchema(context, schemaElement, stack, resolveFor, _.cloneDeep(seenRef), + utils.addToJsonPath(currentPath, [compositeKeyword, index])); }) }; } if (schema.allOf) { - return resolveAllOfSchema(context, schema, stack, resolveFor, _.cloneDeep(seenRef)); + return resolveAllOfSchema(context, schema, stack, resolveFor, _.cloneDeep(seenRef), currentPath); } if (schema.$ref) { @@ -544,14 +592,42 @@ let QUERYPARAM = 'query', seenRef[schemaRef] = true; if (context.schemaCache[schemaRef]) { - schema = context.schemaCache[schemaRef]; + // Also merge readOnly and writeOnly prop cache from schemaCache to global context cache + mergeReadWritePropCache(context, context.schemaCache[schemaRef].readOnlyPropCache, + context.schemaCache[schemaRef].writeOnlyPropCache, currentPath); + + schema = context.schemaCache[schemaRef].schema; } else { + const existingReadPropCache = context.readOnlyPropCache, + existingWritePropCache = context.writeOnlyPropCache; + schema = resolveRefFromSchema(context, schemaRef, stack, _.cloneDeep(seenRef)); - schema = resolveSchema(context, schema, stack, resolveFor, _.cloneDeep(seenRef)); + + /** + * Reset readOnly and writeOnly prop cache before resolving schema to make sure + * we have fresh cache for $ref resolution which will be stored as part of schemaCache + */ + resetReadWritePropCache(context); + schema = _resolveSchema(context, schema, stack, resolveFor, _.cloneDeep(seenRef), ''); // Add the resolved schema to the global schema cache - context.schemaCache[schemaRef] = schema; + context.schemaCache[schemaRef] = { + schema, + readOnlyPropCache: context.readOnlyPropCache, + writeOnlyPropCache: context.writeOnlyPropCache + }; + + // eslint-disable-next-line one-var + const newReadPropCache = context.readOnlyPropCache, + newWritePropCache = context.writeOnlyPropCache; + + // Assign existing readOnly and writeOnly prop cache back to global context cache + context.readOnlyPropCache = existingReadPropCache; + context.writeOnlyPropCache = existingWritePropCache; + + // Merge existing and current cache to make sure we have all the properties in cache + mergeReadWritePropCache(context, newReadPropCache, newWritePropCache, currentPath); } return schema; } @@ -577,7 +653,6 @@ let QUERYPARAM = 'query', if ( property.format === 'decimal' || property.format === 'byte' || - property.format === 'binary' || property.format === 'password' || property.format === 'unix-time' ) { @@ -589,7 +664,10 @@ let QUERYPARAM = 'query', return; } - resolvedSchemaProps[propertyName] = resolveSchema(context, property, stack, resolveFor, _.cloneDeep(seenRef)); + const currentPropPath = utils.addToJsonPath(currentPath, ['properties', propertyName]); + + resolvedSchemaProps[propertyName] = _resolveSchema(context, property, stack, resolveFor, + _.cloneDeep(seenRef), currentPropPath); }); schema.properties = resolvedSchemaProps; @@ -597,7 +675,8 @@ let QUERYPARAM = 'query', } // If schema is of type array else if (concreteUtils.compareTypes(schema.type, SCHEMA_TYPES.array) && schema.items) { - schema.items = resolveSchema(context, schema.items, stack, resolveFor, _.cloneDeep(seenRef)); + schema.items = _resolveSchema(context, schema.items, stack, resolveFor, _.cloneDeep(seenRef), + utils.addToJsonPath(currentPath, ['items'])); } // Any properties to ignored should not be available in schema else if (_.every(SCHEMA_PROPERTIES_TO_EXCLUDE, (schemaKey) => { return !schema.hasOwnProperty(schemaKey); })) { @@ -629,7 +708,8 @@ let QUERYPARAM = 'query', if (schema.hasOwnProperty('additionalProperties')) { schema.additionalProperties = _.isBoolean(schema.additionalProperties) ? schema.additionalProperties : - resolveSchema(context, schema.additionalProperties, stack, resolveFor, _.cloneDeep(seenRef)); + _resolveSchema(context, schema.additionalProperties, stack, resolveFor, _.cloneDeep(seenRef), + utils.addToJsonPath(currentPath, ['additionalProperties'])); schema.type = schema.type || SCHEMA_TYPES.object; } @@ -644,14 +724,72 @@ let QUERYPARAM = 'query', }); } + // Keep track of readOnly and writeOnly properties to resolve request and responses accordingly later. + if (schema.readOnly) { + context.readOnlyPropCache[currentPath] = true; + } + + if (schema.writeOnly) { + context.writeOnlyPropCache[currentPath] = true; + } + return schema; }, + /** + * Wrapper around _resolveSchema which resolves a given schema + * + * @param {Object} context - Global context + * @param {Object} schema - Schema that is to be resolved + * @param {Object} resolutionMeta - Metadata of resolution taking place + * @param {Number} resolutionMeta.stack - Current recursion depth + * @param {String} resolutionMeta.resolveFor - For which action this resolution is to be done + * @param {Object} resolutionMeta.seenRef - Map of all the references that have been resolved + * @param {Boolean} resolutionMeta.isResponseSchema - Whether schema is from response or not + * + * @returns {Object} Returns the object that satisfies the schema + */ + resolveSchema = (context, schema, + { stack = 0, resolveFor = CONVERSION, seenRef = {}, isResponseSchema = false } = {} + ) => { + // reset readOnly and writeOnly prop cache before resolving schema to make sure we have fresh cache + resetReadWritePropCache(context); + + let resolvedSchema = _resolveSchema(context, schema, stack, resolveFor, seenRef); + + /** + * If readOnly or writeOnly properties are present in the schema, we need to clone original schema first. + * Because we modify original resolved schema and delete readOnly or writeOnly properties from it + * depending upon if schema belongs to Request or Response. + * This is done to avoid modifying original schema object and to keep it intact for future use. + */ + if (!_.isEmpty(context.readOnlyPropCache) || !_.isEmpty(context.writeOnlyPropCache)) { + resolvedSchema = _.cloneDeep(resolvedSchema); + } + + if (isResponseSchema) { + _.forOwn(context.writeOnlyPropCache, (value, key) => { + // We need to make sure to remove empty strings via _.compact that are added while forming json-pointer + _.unset(resolvedSchema, utils.getJsonPathArray(key)); + }); + } + else { + _.forOwn(context.readOnlyPropCache, (value, key) => { + // We need to make sure to remove empty strings via _.compact that are added while forming json-pointer + _.unset(resolvedSchema, utils.getJsonPathArray(key)); + }); + } + + return resolvedSchema; + }, + /** * Provides information regarding serialisation of param * * @param {Object} context - Required context from related SchemaPack function * @param {Object} param - OpenAPI Parameter object + * @param {Object} options - Options object + * @param {Boolean} options.isResponseSchema - Whether schema is from response or not * @returns {Object} - Information regarding parameter serialisation. Contains following properties. * { * style - style property defined/inferred from schema @@ -662,7 +800,7 @@ let QUERYPARAM = 'query', * isExplodable - whether params can be exploded (serialised value can contain key and value) * } */ - getParamSerialisationInfo = (context, param) => { + getParamSerialisationInfo = (context, param, { isResponseSchema = false } = {}) => { let paramName = _.get(param, 'name'), paramSchema, style, // style property defined/inferred from schema @@ -680,7 +818,7 @@ let QUERYPARAM = 'query', } // Resolve the ref and composite schemas - paramSchema = resolveSchema(context, param.schema); + paramSchema = resolveSchema(context, param.schema, { isResponseSchema }); isExplodable = paramSchema.type === 'object'; @@ -794,16 +932,20 @@ let QUERYPARAM = 'query', * * @param {Object} context - Required context from related SchemaPack function * @param {Object} param - Parameter that is to be resolved from schema - * @param {String} schemaFormat - Corresponding schema format (can be one of xml/default) + * @param {Object} options - Addition options + * @param {String} options.schemaFormat - Corresponding schema format (can be one of xml/default) + * @param {Boolean} options.isResponseSchema - Whether schema is from response or not * @returns {*} Value of the parameter */ - resolveValueOfParameter = (context, param, schemaFormat = SCHEMA_FORMATS.DEFAULT) => { + resolveValueOfParameter = (context, param, + { schemaFormat = SCHEMA_FORMATS.DEFAULT, isResponseSchema = false } = {} + ) => { if (!param || !param.hasOwnProperty('schema')) { return ''; } const { indentCharacter } = context.computedOptions, - resolvedSchema = resolveSchema(context, param.schema), + resolvedSchema = resolveSchema(context, param.schema, { isResponseSchema }), { parametersResolution } = context.computedOptions, shouldGenerateFromExample = parametersResolution === 'example', hasExample = param.example !== undefined || @@ -841,7 +983,6 @@ let QUERYPARAM = 'query', for (const prop in resolvedSchema.properties) { if (resolvedSchema.properties.hasOwnProperty(prop)) { if ( - resolvedSchema.properties[prop].format === 'binary' || resolvedSchema.properties[prop].format === 'byte' || resolvedSchema.properties[prop].format === 'decimal' ) { @@ -916,9 +1057,19 @@ let QUERYPARAM = 'query', (parameter.enum ? ' (This can only be one of ' + parameter.enum + ')' : ''); }, - serialiseParamsBasedOnStyle = (context, param, paramValue) => { + /** + * Serialise Param based on mentioned style field in schema object + * + * @param {Object} context - Global context object + * @param {Object} param - OpenAPI Parameter object + * @param {*} paramValue - Value of the parameter + * @param {Object} options - Additional options for serialisation + * @param {Boolean} options.isResponseSchema - Whether schema is from response or not + * @returns {Array} - Array of key-value pairs for the parameter + */ + serialiseParamsBasedOnStyle = (context, param, paramValue, { isResponseSchema = false } = {}) => { const { style, explode, startValue, propSeparator, keyValueSeparator, isExplodable } = - getParamSerialisationInfo(context, param), + getParamSerialisationInfo(context, param, { isResponseSchema }), { enableOptionalParameters } = context.computedOptions; let serialisedValue = '', @@ -1080,7 +1231,22 @@ let QUERYPARAM = 'query', /** * Generates postman equivalent examples which contains request and response mappings of - * each example based on examples mentioned ind definition + * each example based on examples mentioned in definition + * + * This matching between request bodies and response bodies are done in following order. + * 1. Try matching keys from request and response examples + * + * (We'll also be considering any request body example with response code as key + * that's matching default response body example if present + * See fro example - test/data/valid_openapi/multiExampleResponseCodeMatching.json) + * + * 2. If any key matching is found, we'll generate example from it and ignore non-matching keys + * + * 3. If no matching key is found, we'll generate examples based on positional matching. + * + * Positional matching means first example in request body will be matched with first example + * in response body and so on. Any left over request or response body for which + * positional matching is not found, we'll use first req/res example. * * @param {Object} context - Global context object * @param {Object} responseExamples - Examples defined in the response @@ -1090,8 +1256,66 @@ let QUERYPARAM = 'query', * @returns {Array} Examples for corresponding operation */ generateExamples = (context, responseExamples, requestBodyExamples, responseBodySchema, isXMLExample) => { - const pmExamples = []; + const pmExamples = [], + responseExampleKeys = _.map(responseExamples, 'key'), + requestBodyExampleKeys = _.map(requestBodyExamples, 'key'), + usedRequestExamples = _.fill(Array(requestBodyExamples.length), false), + exampleKeyComparator = (example, key) => { + return _.toLower(example.key) === _.toLower(key); + }; + + let matchedKeys = _.intersectionBy(responseExampleKeys, requestBodyExampleKeys, _.toLower), + isResponseCodeMatching = false; + + // Only match in case of default response example matching with any request body example + if (!matchedKeys.length && responseExamples.length === 1 && responseExamples[0].key === '_default') { + const responseCodes = _.map(responseExamples, 'responseCode'); + + matchedKeys = _.intersectionBy(responseCodes, requestBodyExampleKeys, _.toLower); + isResponseCodeMatching = matchedKeys.length > 0; + } + + // Do keys matching first and ignore any leftover req/res body for which matching is not found + if (matchedKeys.length) { + _.forEach(matchedKeys, (key) => { + const matchedRequestExamples = _.filter(requestBodyExamples, (example) => { + return exampleKeyComparator(example, key); + }), + responseExample = _.find(responseExamples, (example) => { + // If there is a response code key-matching, then only match with keys based on response code + if (isResponseCodeMatching) { + return example.responseCode === key; + } + + return exampleKeyComparator(example, key); + }); + + let requestExample = _.find(matchedRequestExamples, ['contentType', _.get(responseExample, 'contentType')]), + responseExampleData; + + if (!requestExample) { + requestExample = _.head(matchedRequestExamples); + } + + responseExampleData = getExampleData(context, { [responseExample.key]: responseExample.value }); + + if (isXMLExample) { + responseExampleData = getXMLExampleData(context, responseExampleData, responseBodySchema); + } + pmExamples.push({ + request: getExampleData(context, { [requestExample.key]: requestExample.value }), + response: responseExampleData, + name: _.get(responseExample, 'value.summary') || + (responseExample.key !== '_default' && responseExample.key) || + _.get(requestExample, 'value.summary') || requestExample.key || 'Example' + }); + }); + + return pmExamples; + } + + // No key matching between req and res were found, so perform positional matching now _.forEach(responseExamples, (responseExample, index) => { if (!_.isObject(responseExample)) { @@ -1113,46 +1337,12 @@ let QUERYPARAM = 'query', return; } - requestExample = _.find(requestBodyExamples, (example, index) => { - if ( - example.contentType === responseExample.contentType && - _.toLower(example.key) === _.toLower(responseExample.key) - ) { - requestBodyExamples[index].isUsed = true; - return true; - } - return false; - }); - - // If exact content type is not matching, pick first content type with same example key - if (!requestExample) { - requestExample = _.find(requestBodyExamples, (example, index) => { - if (_.toLower(example.key) === _.toLower(responseExample.key)) { - requestBodyExamples[index].isUsed = true; - return true; - } - return false; - }); + if (requestBodyExamples[index] && !usedRequestExamples[index]) { + requestExample = requestBodyExamples[index]; + usedRequestExamples[index] = true; } - - if (!requestExample) { - if (requestBodyExamples[index] && !requestBodyExamples[index].isUsed) { - requestExample = requestBodyExamples[index]; - requestBodyExamples[index].isUsed = true; - } - else { - for (let i = 0; i < requestBodyExamples.length; i++) { - if (!requestBodyExamples[i].isUsed) { - requestExample = requestBodyExamples[i]; - requestBodyExamples[i].isUsed = true; - break; - } - } - - if (!requestExample) { - requestExample = requestBodyExamples[0]; - } - } + else { + requestExample = requestBodyExamples[0]; } pmExamples.push({ @@ -1163,12 +1353,14 @@ let QUERYPARAM = 'query', }); }); + // eslint-disable-next-line one-var let responseExample, responseExampleData; + // Add any left over request body examples with first response body as matching for (let i = 0; i < requestBodyExamples.length; i++) { - if (!requestBodyExamples[i].isUsed || pmExamples.length === 0) { + if (!usedRequestExamples[i] || pmExamples.length === 0) { if (!responseExample) { responseExample = _.head(responseExamples); @@ -1200,10 +1392,13 @@ let QUERYPARAM = 'query', * @param {Object} requestBodySchema - Schema of the request / response body * @param {String} bodyType - Content type of the body * @param {Boolean} isExampleBody - Whether the body is example body + * @param {String} responseCode - Response code * @param {Object} requestBodyExamples - Examples defined in the request body * @returns {Array} Request / Response body data */ - resolveBodyData = (context, requestBodySchema, bodyType, isExampleBody = false, requestBodyExamples) => { + resolveBodyData = (context, requestBodySchema, bodyType, isExampleBody = false, + responseCode = null, requestBodyExamples = {} + ) => { let { parametersResolution, indentCharacter } = context.computedOptions, headerFamily = getHeaderFamily(bodyType), bodyData = '', @@ -1219,7 +1414,7 @@ let QUERYPARAM = 'query', } if (requestBodySchema.$ref) { - requestBodySchema = resolveSchema(context, requestBodySchema); + requestBodySchema = resolveSchema(context, requestBodySchema, { isResponseSchema: isExampleBody }); } /** @@ -1268,7 +1463,7 @@ let QUERYPARAM = 'query', examples = requestBodySchema.examples || _.get(requestBodySchema, 'schema.examples'); requestBodySchema = requestBodySchema.schema || requestBodySchema; - requestBodySchema = resolveSchema(context, requestBodySchema); + requestBodySchema = resolveSchema(context, requestBodySchema, { isResponseSchema: isExampleBody }); // If schema object has example defined, try to use that if no example is defiend at request body level if (example === undefined && _.get(requestBodySchema, 'example') !== undefined) { @@ -1293,7 +1488,7 @@ let QUERYPARAM = 'query', requestBodySchema = requestBodySchema.schema || requestBodySchema; if (requestBodySchema.$ref) { - requestBodySchema = resolveSchema(context, requestBodySchema); + requestBodySchema = resolveSchema(context, requestBodySchema, { isResponseSchema: isExampleBody }); } if (isBodyTypeXML) { @@ -1312,7 +1507,6 @@ let QUERYPARAM = 'query', } if ( - requestBodySchema.properties[prop].format === 'binary' || requestBodySchema.properties[prop].format === 'byte' || requestBodySchema.properties[prop].format === 'decimal' ) { @@ -1345,7 +1539,8 @@ let QUERYPARAM = 'query', responseExamples = [{ key: '_default', value: bodyData, - contentType: bodyType + contentType: bodyType, + responseCode }]; if (!_.isEmpty(examples)) { @@ -1357,7 +1552,15 @@ let QUERYPARAM = 'query', }; }); } - return generateExamples(context, responseExamples, requestBodyExamples, requestBodySchema, isBodyTypeXML); + + let matchedRequestBodyExamples = _.filter(requestBodyExamples, ['contentType', bodyType]); + + // If content-types are not matching, match with any present content-types + if (_.isEmpty(matchedRequestBodyExamples)) { + matchedRequestBodyExamples = requestBodyExamples; + } + + return generateExamples(context, responseExamples, matchedRequestBodyExamples, requestBodySchema, isBodyTypeXML); } return [{ [bodyKey]: bodyData }]; @@ -1463,7 +1666,7 @@ let QUERYPARAM = 'query', // TODO: Add handling for headers from encoding - if (paramSchema && paramSchema.type === 'binary') { + if (paramSchema && paramSchema.type === 'string' && paramSchema.format === 'binary') { param = { key, value: '', @@ -1585,7 +1788,13 @@ let QUERYPARAM = 'query', resolveRequestBodyForPostmanRequest = (context, operationItem) => { let requestBody = operationItem.requestBody, - requestContent; + requestContent, + encodedRequestBody, + formDataRequestBody, + rawModeRequestBody; + + const { preferredRequestBodyType: optionRequestBodyType } = context.computedOptions, + preferredRequestBodyType = optionRequestBodyType || 'first-listed'; if (!requestBody) { return requestBody; @@ -1604,15 +1813,38 @@ let QUERYPARAM = 'query', }; } - if (requestContent[URLENCODED]) { - return resolveUrlEncodedRequestBodyForPostmanRequest(context, requestContent[URLENCODED]); + for (const contentType in requestContent) { + if (contentType === URLENCODED) { + encodedRequestBody = resolveUrlEncodedRequestBodyForPostmanRequest(context, requestContent[contentType]); + if (preferredRequestBodyType === 'first-listed') { + return encodedRequestBody; + } + } + else if (contentType === FORM_DATA) { + formDataRequestBody = resolveFormDataRequestBodyForPostmanRequest(context, requestContent[contentType]); + if (preferredRequestBodyType === 'first-listed') { + return formDataRequestBody; + } + } + else { + rawModeRequestBody = resolveRawModeRequestBodyForPostmanRequest(context, requestContent); + if (preferredRequestBodyType === 'first-listed') { + return rawModeRequestBody; + } + } } - if (requestContent[FORM_DATA]) { - return resolveFormDataRequestBodyForPostmanRequest(context, requestContent[FORM_DATA]); + // Check if preferredRequestBodyType is provided and return the corresponding request body if available + if (preferredRequestBodyType) { + if (preferredRequestBodyType === 'x-www-form-urlencoded' && encodedRequestBody) { + return encodedRequestBody; + } + else if (preferredRequestBodyType === 'form-data' && formDataRequestBody) { + return formDataRequestBody; + } } - return resolveRawModeRequestBodyForPostmanRequest(context, requestContent); + return rawModeRequestBody; }, resolvePathItemParams = (context, operationParam, pathParam) => { @@ -1810,9 +2042,10 @@ let QUERYPARAM = 'query', * @param {Object} context - Global context object * @param {Object} responseBody - Response body schema * @param {Object} requestBodyExamples - Examples defined in the request body of corresponding operation + * @param {String} code - Response code * @returns {Array} - Postman examples */ - resolveResponseBody = (context, responseBody = {}, requestBodyExamples) => { + resolveResponseBody = (context, responseBody = {}, requestBodyExamples = {}, code = null) => { let responseContent, bodyType, allBodyData, @@ -1827,7 +2060,7 @@ let QUERYPARAM = 'query', } if (responseBody.$ref) { - responseBody = resolveSchema(context, responseBody); + responseBody = resolveSchema(context, responseBody, { isResponseSchema: true }); } responseContent = responseBody.content; @@ -1839,7 +2072,7 @@ let QUERYPARAM = 'query', bodyType = getRawBodyType(responseContent); headerFamily = getHeaderFamily(bodyType); - allBodyData = resolveBodyData(context, responseContent[bodyType], bodyType, true, requestBodyExamples); + allBodyData = resolveBodyData(context, responseContent[bodyType], bodyType, true, code, requestBodyExamples); return _.map(allBodyData, (bodyData) => { let requestBodyData = bodyData.request, @@ -1888,7 +2121,7 @@ let QUERYPARAM = 'query', { includeDeprecated } = context.computedOptions; if (_.has(responseHeaders, '$ref')) { - responseHeaders = resolveSchema(context, responseHeaders); + responseHeaders = resolveSchema(context, responseHeaders, { isResponseSchema: true }); } _.forOwn(responseHeaders, (value, headerName) => { @@ -1900,7 +2133,7 @@ let QUERYPARAM = 'query', return; } - let headerValue = resolveValueOfParameter(context, value); + let headerValue = resolveValueOfParameter(context, value, { isResponseSchema: true }); if (typeof headerValue === 'number' || typeof headerValue === 'boolean') { // the SDK will keep the number-ness, @@ -1911,7 +2144,7 @@ let QUERYPARAM = 'query', } const headerData = Object.assign({}, value, { name: headerName }), - serialisedHeader = serialiseParamsBasedOnStyle(context, headerData, headerValue); + serialisedHeader = serialiseParamsBasedOnStyle(context, headerData, headerValue, { isResponseSchema: true }); headers.push(...serialisedHeader); }); @@ -2012,7 +2245,7 @@ let QUERYPARAM = 'query', // store all request examples which will be used for creation of examples with correct request and response matching if (typeof requestBody === 'object') { if (requestBody.$ref) { - requestBody = resolveSchema(context, requestBody); + requestBody = resolveSchema(context, requestBody, { isResponseSchema: true }); } requestContent = requestBody.content; @@ -2031,7 +2264,8 @@ let QUERYPARAM = 'query', const exampleData = getExampleData(context, { [name]: exampleObj }); if (isBodyTypeXML) { - let bodyData = getXMLExampleData(context, exampleData, resolveSchema(context, content.schema)); + let bodyData = getXMLExampleData(context, exampleData, resolveSchema(context, content.schema, + { isResponseSchema: true })); exampleObj.value = getXmlVersionContent(bodyData); } @@ -2049,10 +2283,11 @@ let QUERYPARAM = 'query', } _.forOwn(operationItem.responses, (responseObj, code) => { - let responseSchema = _.has(responseObj, '$ref') ? resolveSchema(context, responseObj) : responseObj, + let responseSchema = _.has(responseObj, '$ref') ? + resolveSchema(context, responseObj, { isResponseSchema: true }) : responseObj, { includeAuthInfoInExample } = context.computedOptions, auth = request.auth, - resolvedExamples = resolveResponseBody(context, responseSchema, requestBodyExamples) || {}, + resolvedExamples = resolveResponseBody(context, responseSchema, requestBodyExamples, code) || {}, headers = resolveResponseHeaders(context, responseSchema.headers); _.forOwn(resolvedExamples, (resolvedExample = {}) => { @@ -2180,6 +2415,7 @@ module.exports = { }, resolveResponseForPostmanRequest, + resolveRequestBodyForPostmanRequest, resolveRefFromSchema, resolveSchema }; diff --git a/libV2/utils.js b/libV2/utils.js index 00e952bd..9d4df9a8 100644 --- a/libV2/utils.js +++ b/libV2/utils.js @@ -1,5 +1,7 @@ -const sdk = require('postman-collection'), - _ = require('lodash'), +const _ = require('lodash'), + jsonPointer = require('json-pointer'), + { Item } = require('postman-collection/lib/collection/item'), + { Response } = require('postman-collection/lib/collection/response'), // This is the default collection name if one can't be inferred from the OpenAPI spec COLLECTION_NAME = 'Imported from OpenAPI', @@ -30,7 +32,7 @@ const sdk = require('postman-collection'), response.code = response.code.replace(/X|x/g, '0'); response.code = response.code === 'default' ? 500 : _.toSafeInteger(response.code); - let sdkResponse = new sdk.Response({ + let sdkResponse = new Response({ name: response.name, code: response.code, header: response.headers, @@ -50,7 +52,7 @@ const sdk = require('postman-collection'), return sdkResponse; }, generateRequestItemObject = (requestObject) => { - const requestItem = new sdk.Item(requestObject), + const requestItem = new Item(requestObject), queryParams = _.get(requestObject, 'request.params.queryParams'), pathParams = _.get(requestObject, 'request.params.pathParams', []), headers = _.get(requestObject, 'request.headers', []), @@ -199,6 +201,48 @@ module.exports = { return title; }, + /** + * Adds provided property array to the given JSON path + * + * @param {string} jsonPath - JSON path to which properties should be added + * @param {array} propArray - Array of properties to be added to JSON path + * @returns {string} - Combined JSON path + */ + addToJsonPath: function (jsonPath, propArray) { + const jsonPathArray = jsonPointer.parse(jsonPath), + escapedPropArray = _.map(propArray, (prop) => { + return jsonPointer.escape(prop); + }); + + return jsonPointer.compile(jsonPathArray.concat(escapedPropArray)); + }, + + /** + * Merges two JSON paths. i.e. Parent JSON path and Child JSON path + * + * @param {string} parentJsonPath - Parent JSON path + * @param {string} childJsonPath - Child JSON path + * @returns {string} - Merged JSON path + */ + mergeJsonPath: function (parentJsonPath, childJsonPath) { + let jsonPathArray = jsonPointer.parse(parentJsonPath); + + // Merges childJsonPath with parentJsonPath + jsonPathArray = jsonPathArray.concat(jsonPointer.parse(childJsonPath)); + + return jsonPointer.compile(jsonPathArray); + }, + + /** + * Gets JSON path in array from string JSON path + * + * @param {string} jsonPath - input JSON path + * @returns {array} - Parsed JSON path (each part is distributed in an array) + */ + getJsonPathArray: function (jsonPath) { + return jsonPointer.parse(jsonPointer.unescape(jsonPath)); + }, + generatePmResponseObject, generateRequestItemObject }; diff --git a/libV2/validationUtils.js b/libV2/validationUtils.js index 2efd431d..ceabf1d5 100644 --- a/libV2/validationUtils.js +++ b/libV2/validationUtils.js @@ -2,7 +2,10 @@ // TODO: REMOVE THIS ☝🏻 const _ = require('lodash'), - sdk = require('postman-collection'), + { Header } = require('postman-collection/lib/collection/header'), + { QueryParam } = require('postman-collection/lib/collection/query-param'), + { Url } = require('postman-collection/lib/collection/url'), + { Variable } = require('postman-collection/lib/collection/variable'), async = require('async'), crypto = require('crypto'), schemaFaker = require('../assets/json-schema-faker.js'), @@ -82,7 +85,8 @@ schemaFaker.option({ maxItems: 20, // limit on maximum number of items faked for (type: array) useDefaultValue: true, ignoreMissingRefs: true, - avoidExampleItemsLength: true // option to avoid validating type array schema example's minItems and maxItems props. + avoidExampleItemsLength: true, // option to avoid validating type array schema example's minItems and maxItems props. + failOnInvalidFormat: false }); /** @@ -155,7 +159,10 @@ function safeSchemaFaker (context, oldSchema, resolveFor, parameterSourceOption, * i.e. For array it'll add maxItems = 2. This should be avoided as we'll again be needing non-mutated schema * in further VALIDATION use cases as needed. */ - resolvedSchema = resolveSchema(context, _.cloneDeep(oldSchema), 0, _.toLower(PROCESSING_TYPE.CONVERSION)); + resolvedSchema = resolveSchema(context, _.cloneDeep(oldSchema), { + resolveFor: _.toLower(PROCESSING_TYPE.CONVERSION), + isResponseSchema: parameterSourceOption === PARAMETER_SOURCE.RESPONSE + }); resolvedSchema = concreteUtils.fixExamplesByVersion(resolvedSchema); key = JSON.stringify(resolvedSchema); @@ -400,8 +407,10 @@ function getParameterDescription (parameter) { */ function getParamSerialisationInfo (param, parameterSource, components, options) { var paramName = _.get(param, 'name'), - paramSchema = resolveSchema(getDefaultContext(options, components), _.cloneDeep(param.schema), - 0, PROCESSING_TYPE.VALIDATION), + paramSchema = resolveSchema(getDefaultContext(options, components), _.cloneDeep(param.schema), { + resolveFor: PROCESSING_TYPE.VALIDATION, + isResponseSchema: parameterSource === PARAMETER_SOURCE.RESPONSE + }), style, // style property defined/inferred from schema explode, // explode property defined/inferred from schema propSeparator, // separates two properties or values @@ -490,8 +499,10 @@ function getParamSerialisationInfo (param, parameterSource, components, options) */ function deserialiseParamValue (param, paramValue, parameterSource, components, options) { var constructedValue, - paramSchema = resolveSchema(getDefaultContext(options, components), _.cloneDeep(param.schema), - 0, PROCESSING_TYPE.VALIDATION), + paramSchema = resolveSchema(getDefaultContext(options, components), _.cloneDeep(param.schema), { + resolveFor: PROCESSING_TYPE.VALIDATION, + isResponseSchema: parameterSource === PARAMETER_SOURCE.RESPONSE + }), isEvenNumber = (num) => { return (num % 2 === 0); }, @@ -853,9 +864,9 @@ function checkContentTypeHeader (headers, transactionPathPrefix, schemaPathPrefi */ function generateSdkParam (param, location) { const sdkElementMap = { - 'query': sdk.QueryParam, - 'header': sdk.Header, - 'path': sdk.Variable + 'query': QueryParam, + 'header': Header, + 'path': Variable }; let generatedParam = { @@ -1012,7 +1023,7 @@ function convertToPmCollectionVariables (serverVariables, keyName, serverUrl = ' if (serverVariables) { _.forOwn(serverVariables, (value, key) => { let description = getParameterDescription(value); - variables.push(new sdk.Variable({ + variables.push(new Variable({ key: key, value: value.default || '', description: description @@ -1020,7 +1031,7 @@ function convertToPmCollectionVariables (serverVariables, keyName, serverUrl = ' }); } if (keyName) { - variables.push(new sdk.Variable({ + variables.push(new Variable({ key: keyName, value: serverUrl, type: 'string' @@ -1331,7 +1342,10 @@ function checkValueAgainstSchema (context, property, jsonPathPrefix, txnParamNam invalidJson = false, valueToUse = value, - schema = resolveSchema(context, openApiSchemaObj, 0, PROCESSING_TYPE.VALIDATION), + schema = resolveSchema(context, openApiSchemaObj, { + resolveFor: PROCESSING_TYPE.VALIDATION, + isResponseSchema: parameterSourceOption === PARAMETER_SOURCE.RESPONSE + }), compositeSchema = schema.oneOf || schema.anyOf, compareTypes = _.get(context, 'concreteUtils.compareTypes') || concreteUtils.compareTypes; @@ -1707,7 +1721,9 @@ function checkPathVariables (context, matchedPathData, transactionPathPrefix, sc }; if (options.suggestAvailableFixes) { - const resolvedSchema = resolveSchema(context, pathVar.schema, 0, PROCESSING_TYPE.VALIDATION); + const resolvedSchema = resolveSchema(context, pathVar.schema, { + resolveFor: PROCESSING_TYPE.VALIDATION + }); mismatchObj.suggestedFix = { key: pathVar.name, @@ -1754,8 +1770,9 @@ function checkQueryParams (context, queryParams, transactionPathPrefix, schemaPa // below will make sure for exploded params actual schema of property present in collection is present _.forEach(schemaParams, (param) => { let pathPrefix = param.pathPrefix, - paramSchema = resolveSchema(context, _.cloneDeep(param.schema), - 0, PROCESSING_TYPE.VALIDATION), + paramSchema = resolveSchema(context, _.cloneDeep(param.schema), { + resolveFor: PROCESSING_TYPE.VALIDATION + }), { style, explode } = getParamSerialisationInfo(param, PARAMETER_SOURCE.REQUEST, components, options), encodingObj = { [param.name]: { style, explode } }, metaInfo = { @@ -1858,7 +1875,9 @@ function checkQueryParams (context, queryParams, transactionPathPrefix, schemaPa }; if (options.suggestAvailableFixes) { - const resolvedSchema = resolveSchema(context, qp.schema, 0, PROCESSING_TYPE.VALIDATION); + const resolvedSchema = resolveSchema(context, qp.schema, { + resolveFor: PROCESSING_TYPE.VALIDATION + }); mismatchObj.suggestedFix = { key: qp.name, @@ -1996,7 +2015,9 @@ function checkRequestHeaders (context, headers, transactionPathPrefix, schemaPat }; if (options.suggestAvailableFixes) { - const resolvedSchema = resolveSchema(context, header.schema, 0, PROCESSING_TYPE.VALIDATION); + const resolvedSchema = resolveSchema(context, header.schema, { + resolveFor: PROCESSING_TYPE.VALIDATION + }); mismatchObj.suggestedFix = { key: header.name, @@ -2127,7 +2148,10 @@ function checkResponseHeaders (context, schemaResponse, headers, transactionPath }; if (options.suggestAvailableFixes) { - const resolvedSchema = resolveSchema(context, header.schema, 0, PROCESSING_TYPE.VALIDATION); + const resolvedSchema = resolveSchema(context, header.schema, { + resolveFor: PROCESSING_TYPE.VALIDATION, + isResponseSchema: true + }); mismatchObj.suggestedFix = { key: header.name, @@ -2135,7 +2159,7 @@ function checkResponseHeaders (context, schemaResponse, headers, transactionPath suggestedValue: { key: header.name, value: safeSchemaFaker(context, resolvedSchema || {}, PROCESSING_TYPE.VALIDATION, - PARAMETER_SOURCE.REQUEST, components, SCHEMA_FORMATS.DEFAULT, schemaCache), + PARAMETER_SOURCE.RESPONSE, components, SCHEMA_FORMATS.DEFAULT, schemaCache), description: getParameterDescription(header) } }; @@ -2200,8 +2224,9 @@ function checkRequestBody (context, requestBody, transactionPathPrefix, schemaPa return param.value !== OAS_NOT_SUPPORTED; }); - urlencodedBodySchema = resolveSchema(context, urlencodedBodySchema, - 0, PROCESSING_TYPE.VALIDATION); + urlencodedBodySchema = resolveSchema(context, urlencodedBodySchema, { + resolveFor: PROCESSING_TYPE.VALIDATION + }); resolvedSchemaParams = resolveFormParamSchema(urlencodedBodySchema, '', encodingObj, filteredUrlEncodedBody, {}, components, options); @@ -2309,7 +2334,9 @@ function checkRequestBody (context, requestBody, transactionPathPrefix, schemaPa }; if (options.suggestAvailableFixes) { - const resolvedSchema = resolveSchema(context, uParam.schema, 0, PROCESSING_TYPE.VALIDATION); + const resolvedSchema = resolveSchema(context, uParam.schema, { + resolveFor: PROCESSING_TYPE.VALIDATION + }); mismatchObj.suggestedFix = { key: uParam.name, @@ -2535,7 +2562,7 @@ module.exports = { queryParams = [...(requestUrl.query || [])]; // SDK URL object. Get raw string representation. - requestUrl = (new sdk.Url(requestUrl)).toString(); + requestUrl = (new Url(requestUrl)).toString(); } // 1. Look at transaction.request.URL + method, and find matching request from schema diff --git a/package-lock.json b/package-lock.json index e53adc6f..aa8f5528 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openapi-to-postmanv2", - "version": "4.20.0", + "version": "4.24.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "openapi-to-postmanv2", - "version": "4.20.0", + "version": "4.24.0", "license": "Apache-2.0", "dependencies": { "ajv": "8.11.0", @@ -16,14 +16,15 @@ "commander": "2.20.3", "graphlib": "2.1.8", "js-yaml": "4.1.0", + "json-pointer": "0.6.2", "json-schema-merge-allof": "0.8.1", "lodash": "4.17.21", + "neotraverse": "0.6.15", "oas-resolver-browser": "2.5.6", "object-hash": "3.0.0", "path-browserify": "1.0.1", - "postman-collection": "4.2.1", + "postman-collection": "^4.4.0", "swagger2openapi": "7.0.8", - "traverse": "0.6.6", "yaml": "1.10.2" }, "bin": { @@ -110,9 +111,9 @@ } }, "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -162,9 +163,9 @@ } }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -1809,6 +1810,11 @@ "is-callable": "^1.1.3" } }, + "node_modules/foreach": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", + "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" + }, "node_modules/foreground-child": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", @@ -2704,9 +2710,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -2905,6 +2911,14 @@ "node": ">=4" } }, + "node_modules/json-pointer": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", + "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", + "dependencies": { + "foreach": "^2.0.4" + } + }, "node_modules/json-schema-compare": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", @@ -3041,9 +3055,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -3261,6 +3275,14 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/neotraverse": { + "version": "0.6.15", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.15.tgz", + "integrity": "sha512-HZpdkco+JeXq0G+WWpMJ4NsX3pqb5O7eR9uGz3FfoFt+LYzU8iRWp49nJtud6hsDoywM8tIrDo3gjgmOqJA8LA==", + "engines": { + "node": ">= 10" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -4285,9 +4307,9 @@ } }, "node_modules/postman-collection": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/postman-collection/-/postman-collection-4.2.1.tgz", - "integrity": "sha512-DFLt3/yu8+ldtOTIzmBUctoupKJBOVK4NZO0t68K2lIir9smQg7OdQTBjOXYy+PDh7u0pSDvD66tm93eBHEPHA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/postman-collection/-/postman-collection-4.4.0.tgz", + "integrity": "sha512-2BGDFcUwlK08CqZFUlIC8kwRJueVzPjZnnokWPtJCd9f2J06HBQpGL7t2P1Ud1NEsK9NHq9wdipUhWLOPj5s/Q==", "dependencies": { "@faker-js/faker": "5.5.3", "file-type": "3.9.0", @@ -4631,9 +4653,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "bin": { "semver": "bin/semver" @@ -5224,11 +5246,6 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "node_modules/traverse": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", - "integrity": "sha512-kdf4JKs8lbARxWdp7RKdNzoJBhGUcIalSYibuGyHJbmk40pOysQ0+QPvlkCOICOivDWU2IJo2rkrxyTK2AH4fw==" - }, "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -5465,9 +5482,9 @@ } }, "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", + "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -5700,9 +5717,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -5742,9 +5759,9 @@ } }, "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true }, "yallist": { @@ -7009,6 +7026,11 @@ "is-callable": "^1.1.3" } }, + "foreach": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", + "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" + }, "foreground-child": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", @@ -7626,9 +7648,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -7781,6 +7803,14 @@ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-pointer": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", + "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", + "requires": { + "foreach": "^2.0.4" + } + }, "json-schema-compare": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", @@ -7890,9 +7920,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -8066,6 +8096,11 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "neotraverse": { + "version": "0.6.15", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.15.tgz", + "integrity": "sha512-HZpdkco+JeXq0G+WWpMJ4NsX3pqb5O7eR9uGz3FfoFt+LYzU8iRWp49nJtud6hsDoywM8tIrDo3gjgmOqJA8LA==" + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -8837,9 +8872,9 @@ } }, "postman-collection": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/postman-collection/-/postman-collection-4.2.1.tgz", - "integrity": "sha512-DFLt3/yu8+ldtOTIzmBUctoupKJBOVK4NZO0t68K2lIir9smQg7OdQTBjOXYy+PDh7u0pSDvD66tm93eBHEPHA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/postman-collection/-/postman-collection-4.4.0.tgz", + "integrity": "sha512-2BGDFcUwlK08CqZFUlIC8kwRJueVzPjZnnokWPtJCd9f2J06HBQpGL7t2P1Ud1NEsK9NHq9wdipUhWLOPj5s/Q==", "requires": { "@faker-js/faker": "5.5.3", "file-type": "3.9.0", @@ -9106,9 +9141,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true }, "set-blocking": { @@ -9570,11 +9605,6 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "traverse": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", - "integrity": "sha512-kdf4JKs8lbARxWdp7RKdNzoJBhGUcIalSYibuGyHJbmk40pOysQ0+QPvlkCOICOivDWU2IJo2rkrxyTK2AH4fw==" - }, "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -9761,9 +9791,9 @@ } }, "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", + "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", "dev": true }, "wrap-ansi": { diff --git a/package.json b/package.json index ff9b5789..7283bc0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openapi-to-postmanv2", - "version": "4.20.0", + "version": "4.24.0", "description": "Convert a given OpenAPI specification to Postman Collection v2.0", "homepage": "https://github.com/postmanlabs/openapi-to-postman", "bugs": "https://github.com/postmanlabs/openapi-to-postman/issues", @@ -122,15 +122,16 @@ "async": "3.2.4", "commander": "2.20.3", "js-yaml": "4.1.0", + "json-pointer": "0.6.2", "json-schema-merge-allof": "0.8.1", "lodash": "4.17.21", + "neotraverse": "0.6.15", "oas-resolver-browser": "2.5.6", "object-hash": "3.0.0", "graphlib": "2.1.8", "path-browserify": "1.0.1", - "postman-collection": "4.2.1", + "postman-collection": "^4.4.0", "swagger2openapi": "7.0.8", - "traverse": "0.6.6", "yaml": "1.10.2" }, "author": "Postman Labs ", diff --git a/test/data/valid_openapi/allOfAdditionalProperties.json b/test/data/valid_openapi/allOfAdditionalProperties.json new file mode 100644 index 00000000..f7fd10e1 --- /dev/null +++ b/test/data/valid_openapi/allOfAdditionalProperties.json @@ -0,0 +1,157 @@ +{ + "x-generator": "NSwag v14.0.3.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))", + "openapi": "3.0.0", + "info": { + "title": "Join API", + "version": "1.0.0" + }, + "paths": { + "/api/Membership": { + "post": { + "tags": [ + "Membership" + ], + "operationId": "PostMember", + "requestBody": { + "x-name": "query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardJoinCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardJoinDto" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "GetMemberDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "memberId": { + "type": "integer", + "format": "int32" + }, + "username": { + "type": "string", + "nullable": true + }, + "comment": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "StandardJoinDto": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseJoinDto" + }, + { + "type": "object", + "additionalProperties": false + } + ] + }, + "BaseJoinDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "memberId": { + "type": "integer", + "format": "int32" + }, + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + } + } + }, + "StandardJoinCommand": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseJoinCommandOfStandardJoinDto" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string", + "nullable": true + }, + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + }, + "address1": { + "type": "string", + "nullable": true + }, + "city": { + "type": "string", + "nullable": true + }, + "state": { + "type": "string" + }, + "countryCode": { + "type": "string", + "nullable": true + }, + "zipCode": { + "type": "string", + "nullable": true + }, + "phoneNumber": { + "type": "string", + "nullable": true + } + } + } + ] + }, + "BaseJoinCommandOfStandardJoinDto": { + "type": "object", + "x-abstract": true, + "additionalProperties": false, + "properties": { + "campaignId": { + "type": "integer", + "format": "int32" + } + } + } + } + } +} + diff --git a/test/data/valid_openapi/duplicateCollectionVars.json b/test/data/valid_openapi/duplicateCollectionVars.json new file mode 100644 index 00000000..451f8022 --- /dev/null +++ b/test/data/valid_openapi/duplicateCollectionVars.json @@ -0,0 +1,56 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "MyTitle", + "description": "My Description", + "version": "1.0.0", + "x-ms-generated-by": { + "toolName": "Microsoft.OpenApi.OData", + "toolVersion": "1.0.9.0" + } + }, + "paths": { + "/Path1({MyParam})": { + "description": "My path1 description", + "get": { + "tags": [ + "MyTag" + ], + "summary": "does path1", + "operationId": "Path1", + "parameters": [ + { + "name": "MyParam", + "in": "path", + "description": "My Param", + "schema": { + "type": "string", + "nullable": true + } + } + ] + } + }, + "/Path2({MyParam})": { + "description": "My path2 description", + "get": { + "tags": [ + "MyTag" + ], + "summary": "does path2", + "operationId": "Path2", + "parameters": [ + { + "name": "MyParam", + "in": "path", + "description": "My Param", + "schema": { + "type": "string", + "nullable": true + } + } + ] + } + } + } +} diff --git a/test/data/valid_openapi/form-binary-file.json b/test/data/valid_openapi/form-binary-file.json new file mode 100644 index 00000000..9b674793 --- /dev/null +++ b/test/data/valid_openapi/form-binary-file.json @@ -0,0 +1,37 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Form Data - Binary - OpenAPI 3.0", + "version": "1.0.0" + }, + "paths": { + "/uploadImage": { + "post": { + "summary": "uploads an image", + "description": "", + "operationId": "uploadFile", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "inputfile": { + "type": "string", + "format": "binary", + "description": "The file to be uploaded." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "successful operation" + } + } + } + } + } +} diff --git a/test/data/valid_openapi/multiContentTypesMultiExample.json b/test/data/valid_openapi/multiContentTypesMultiExample.json new file mode 100644 index 00000000..638aea32 --- /dev/null +++ b/test/data/valid_openapi/multiContentTypesMultiExample.json @@ -0,0 +1,127 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "license": { + "name": "MIT" + } + }, + "servers": [{ + "url": "http://petstore.swagger.io/v1" + }], + "paths": { + "/pets": { + "post": { + "summary": "List all pets", + "operationId": "pets - updated", + "tags": [ + "pets" + ], + "parameters": [{ + "name": "limit1", + "in": "query", + "description": "How many items to return at one time (max 100)", + "schema": { + "type": "integer", + "format": "int32" + } + }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "ok_example": { + "value": { + "message": "ok" + } + }, + "not_ok_example": { + "value": { + "message": "fail" + } + } + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "ok_example": { + "value": { + "message": "ok" + } + }, + "not_ok_example": { + "value": { + "message": "fail" + } + } + } + } + } + }, + "responses": { + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "message": "Not Found" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "message": "Not Found" + } + } + } + }, + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "message": "Found", + "code": 200123 + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/test/data/valid_openapi/multiExampleMatchingRequestResponse.yaml b/test/data/valid_openapi/multiExampleMatchingRequestResponse.yaml index 050a9347..ea5a1d74 100644 --- a/test/data/valid_openapi/multiExampleMatchingRequestResponse.yaml +++ b/test/data/valid_openapi/multiExampleMatchingRequestResponse.yaml @@ -22,6 +22,10 @@ paths: value: includedFields: - user + extra-value: + value: + includedFields: + - eyeColor responses: 200: description: None @@ -44,6 +48,10 @@ paths: { "user": 1 } + extra-value-2: + value: + includedFields: + - eyeColor components: schemas: World: diff --git a/test/data/valid_openapi/multiExampleRequestVariousResponse.yaml b/test/data/valid_openapi/multiExampleRequestVariousResponse.yaml new file mode 100644 index 00000000..7910b60e --- /dev/null +++ b/test/data/valid_openapi/multiExampleRequestVariousResponse.yaml @@ -0,0 +1,106 @@ +openapi: 3.0.0 +info: + title: None + version: 1.0.0 + description: None +paths: + /v1: + post: + requestBody: + content: + 'application/json': + schema: + $ref: "#/components/schemas/World" + examples: + valid-request: + value: + includedFields: + - user + - height + - weight + missing-required-parameter: + value: + includedFields: + - eyeColor + responses: + 200: + description: None + content: + 'application/json': + schema: + $ref: "#/components/schemas/Request" + examples: + valid-request: + summary: Request with only required params + value: + { + "user": 1 + } + not-matching-key: + summary: Complete request + value: + { + "user": 99, + "height": 168, + "weight": 44 + } + 400: + description: None + content: + 'application/json': + schema: + $ref: "#/components/schemas/Request" + examples: + missing-required-parameter: + summary: Request with only bad params + value: + { + "eyeColor": "gray" + } + 500: + description: None + content: + 'application/json': + schema: + $ref: "#/components/schemas/Request" + examples: + not-matching-key-1: + summary: Failed request - Negative user + value: + { + "user": -99 + } + not-matching-key-2: + summary: Failed request - All negatives + value: + { + "user": -999, + "height": -168, + "weight": -44 + } + +components: + schemas: + World: + type: object + properties: + includedFields: + type: array + Request: + type: object + required: + - user + - height + - weight + properties: + user: + type: integer + description: None + height: + type: integer + description: None + weight: + type: integer + description: None + eyeColor: + type: string diff --git a/test/data/valid_openapi/multiExampleResponseCodeMatching.json b/test/data/valid_openapi/multiExampleResponseCodeMatching.json new file mode 100644 index 00000000..3b0292c4 --- /dev/null +++ b/test/data/valid_openapi/multiExampleResponseCodeMatching.json @@ -0,0 +1,282 @@ +{ + "x-generator": "NSwag v13.19.0.0 (NJsonSchema v10.9.0.0 (Newtonsoft.Json v13.0.0.0))", + "openapi": "3.0.0", + "info": { + "title": "Postman Example API", + "description": "postman Test. \r\n\r\n © Copyright 2024.", + "version": "v1" + }, + "servers": [ + { + "url": "https://localhost:1234" + } + ], + "paths": { + "/addUser": { + "post": { + "tags": [ + "PostmanExample" + ], + "summary": "Add User", + "description": "Add new user to system and define his access.", + "operationId": "PostmanExample_AddUser", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddUserCommand" + }, + "examples": { + "200": { + "value": { + "userDetail": { + "roleId": 1, + "department": "Admin 1", + "email": "123@gmail.com" + } + } + }, + "400": { + "value": { + "userDetail": { + "roleId": null, + "department": "Admin 1", + "email": "" + } + } + }, + "404": { + "value": { + "userDetail": { + "roleId": 0, + "department": "Admin 0", + "email": "123@gmail.com" + } + } + }, + "409": { + "value": { + "userDetail": { + "roleId": 1, + "department": "Admin 1", + "email": "123@gmail.com" + } + } + }, + "500": { + "value": { + "userDetail": { + "roleId": 0, + "department": null, + "email": null + } + } + } + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + }, + "example": { + "userId": 12 + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestResponse" + }, + "example": { + "hasErrorMessage": true, + "errorMessage": "Bad Request", + "validationsErrors": [ + { + "propertyName": "RoleID", + "errorMessage": "Can not be null" + } + ] + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundResponse" + }, + "example": { + "message": "AddUserDetailsCommand : User Role Not Found" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictErrorResponse" + }, + "example": { + "message": "AddUserDetailsCommand : Duplicate" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnexpectedErrorResponse" + }, + "example": { + "message": "AddUserDetailsCommand : System Error message" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UserResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/Response" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "userId": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + } + ] + }, + "Response": { + "type": "object", + "additionalProperties": false + }, + "BadRequestResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "hasErrorMessage": { + "type": "boolean" + }, + "errorMessage": { + "type": "string", + "nullable": true + }, + "validationsErrors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "ValidationError": { + "type": "object", + "additionalProperties": false, + "properties": { + "propertyName": { + "type": "string" + }, + "errorMessage": { + "type": "string" + } + } + }, + "NotFoundResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + } + }, + "ConflictErrorResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + } + }, + "UnexpectedErrorResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + } + }, + "AddUserCommand": { + "allOf": [ + { + "$ref": "#/components/schemas/AddUserCommandResponse" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "userDetail": { + "$ref": "#/components/schemas/UserInformationDto" + } + } + } + ] + }, + "UserInformationDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "roleId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "department": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "AddUserCommandResponse": { + "type": "object", + "additionalProperties": false + } + } + } +} diff --git a/test/data/valid_openapi/readOnly.json b/test/data/valid_openapi/readOnly.json index b7115914..e44b1fab 100644 --- a/test/data/valid_openapi/readOnly.json +++ b/test/data/valid_openapi/readOnly.json @@ -21,7 +21,20 @@ "type": "array", "items": { "type": "object", - "$ref": "#/components/schemas/Pet" + "properties": { + "id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string", + "writeOnly": true + } + } } } } @@ -59,25 +72,5 @@ } } } - }, - "components": { - "schemas": { - "Pet": { - "properties": { - "id": { - "type": "integer", - "format": "int64", - "readOnly": true - }, - "name": { - "type": "string" - }, - "tag": { - "type": "string", - "writeOnly": true - } - } - } - } } } diff --git a/test/data/valid_openapi/readOnlyAllOf.json b/test/data/valid_openapi/readOnlyAllOf.json new file mode 100644 index 00000000..271b7bd1 --- /dev/null +++ b/test/data/valid_openapi/readOnlyAllOf.json @@ -0,0 +1,95 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore" + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "Successfull", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserPet" + } + } + } + } + } + } + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPet" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully created a pet" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "properties": { + "id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string", + "writeOnly": true + } + } + }, + "User": { + "properties": { + "user.id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "user.name": { + "type": "string" + }, + "user.tag": { + "type": "string", + "writeOnly": true + } + } + }, + "UserPet": { + "allOf": [ + { + "$ref": "#/components/schemas/Pet" + }, + { + "$ref": "#/components/schemas/User" + } + ] + } + } + } +} diff --git a/test/data/valid_openapi/readOnlyNested.json b/test/data/valid_openapi/readOnlyNested.json new file mode 100644 index 00000000..54d707b2 --- /dev/null +++ b/test/data/valid_openapi/readOnlyNested.json @@ -0,0 +1,101 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore" + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "Successfull", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully created a pet" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "properties": { + "id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string", + "writeOnly": true + }, + "address": { + "type": "object", + "properties": { + "addressCode": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + }, + "readOnly": true + }, + "city": { + "type": "string" + }, + "state": { + "type": "string", + "writeOnly": true + } + } + } + }, + "additionalProperties": { + "type": "string", + "writeOnly": true + } + }, + "User": { + "properties": { + "name": { + "type": "string" + }, + "pet": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + } +} diff --git a/test/data/valid_openapi/readOnlyOneOf.json b/test/data/valid_openapi/readOnlyOneOf.json new file mode 100644 index 00000000..1561c6d6 --- /dev/null +++ b/test/data/valid_openapi/readOnlyOneOf.json @@ -0,0 +1,95 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore" + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "Successfull", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserPet" + } + } + } + } + } + } + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPet" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully created a pet" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "properties": { + "id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string", + "writeOnly": true + } + } + }, + "User": { + "properties": { + "user/id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "user/name": { + "type": "string" + }, + "user/tag": { + "type": "string", + "writeOnly": true + } + } + }, + "UserPet": { + "oneOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "$ref": "#/components/schemas/Pet" + } + ] + } + } + } +} diff --git a/test/data/valid_openapi/readOnlyRef.json b/test/data/valid_openapi/readOnlyRef.json new file mode 100644 index 00000000..faf1dd1b --- /dev/null +++ b/test/data/valid_openapi/readOnlyRef.json @@ -0,0 +1,69 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore" + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "Successfull", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + } + } + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully created a pet" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "properties": { + "id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string", + "writeOnly": true + } + } + } + } + } +} diff --git a/test/system/repository.test.js b/test/system/repository.test.js index 9c15b46e..2d2fe4d1 100644 --- a/test/system/repository.test.js +++ b/test/system/repository.test.js @@ -77,13 +77,10 @@ describe('project repository', function () { expect(json.dependencies).to.be.a('object'); }); - // Unskip before merging - it('must point to a valid and precise (no * or ^) semver', function () { - json.dependencies && Object.keys(json.dependencies).forEach(function (item) { - expect(json.dependencies[item]).to.match(new RegExp('(^((\\d+)\\.(\\d+)\\.(\\d+)|' + - '(^npm:[\\dA-Za-z\\-]+@(\\d+)\\.(\\d+)\\.(\\d+)))(?:-([\\dA-Za-z\\-]+(?:\\.[\\dA-Za-z\\-]+)*))?' + - '(?:\\+([\\dA-Za-z\\-]+(?:\\.[\\dA-Za-z\\-]+)*))?)$')); - }); + it('should have a valid version string in form of ..', function () { + expect(json.version) + // eslint-disable-next-line max-len, security/detect-unsafe-regex + .to.match(/^((\d+)\.(\d+)\.(\d+))(?:-([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?(?:\+([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?$/); }); }); @@ -92,11 +89,11 @@ describe('project repository', function () { expect(json.devDependencies).to.be.a('object'); }); - it('must point to a valid and precise (no * or ^) semver', function () { - json.devDependencies && Object.keys(json.devDependencies).forEach(function (item) { - expect(json.devDependencies[item]).to.match(new RegExp('(^((\\d+)\\.(\\d+)\\.(\\d+)|' + - '(^npm:[\\dA-Za-z\\-]+@(\\d+)\\.(\\d+)\\.(\\d+)))(?:-([\\dA-Za-z\\-]+(?:\\.[\\dA-Za-z\\-]+)*))?' + - '(?:\\+([\\dA-Za-z\\-]+(?:\\.[\\dA-Za-z\\-]+)*))?)$')); + it('should point to a valid semver', function () { + Object.keys(json.devDependencies).forEach(function (dependencyName) { + // eslint-disable-next-line security/detect-non-literal-regexp + expect(json.devDependencies[dependencyName]).to.match(new RegExp('((\\d+)\\.(\\d+)\\.(\\d+))(?:-' + + '([\\dA-Za-z\\-]+(?:\\.[\\dA-Za-z\\-]+)*))?(?:\\+([\\dA-Za-z\\-]+(?:\\.[\\dA-Za-z\\-]+)*))?$')); }); }); diff --git a/test/system/structure.test.js b/test/system/structure.test.js index cc49ba48..f2bbed18 100644 --- a/test/system/structure.test.js +++ b/test/system/structure.test.js @@ -30,7 +30,8 @@ const optionIds = [ 'includeDeprecated', 'parametersResolution', 'disabledParametersValidation', - 'alwaysInheritAuthentication' + 'alwaysInheritAuthentication', + 'preferredRequestBodyType' ], expectedOptions = { collapseFolders: { @@ -38,7 +39,7 @@ const optionIds = [ type: 'boolean', default: true, description: 'Importing will collapse all folders that have only one child element and lack ' + - 'persistent folder-level data.' + 'persistent folder-level data.' }, requestParametersResolution: { name: 'Request parameter generation', @@ -46,9 +47,9 @@ const optionIds = [ default: 'Schema', availableOptions: ['Example', 'Schema'], description: 'Select whether to generate the request parameters based on the' + - ' [schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject) or the' + - ' [example](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#exampleObject)' + - ' in the schema.' + ' [schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject) or the' + + ' [example](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#exampleObject)' + + ' in the schema.' }, exampleParametersResolution: { name: 'Response parameter generation', @@ -56,9 +57,9 @@ const optionIds = [ default: 'Example', availableOptions: ['Example', 'Schema'], description: 'Select whether to generate the response parameters based on the' + - ' [schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject) or the' + - ' [example](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#exampleObject)' + - ' in the schema.' + ' [schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject) or the' + + ' [example](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#exampleObject)' + + ' in the schema.' }, folderStrategy: { name: 'Folder organization', @@ -88,8 +89,8 @@ const optionIds = [ default: 'Fallback', availableOptions: ['Url', 'Fallback'], description: 'Determines how the requests inside the generated collection will be named.' + - ' If “Fallback” is selected, the request will be named after one of the following schema' + - ' values: `summary`, `operationId`, `description`, `url`.' + ' If “Fallback” is selected, the request will be named after one of the following schema' + + ' values: `summary`, `operationId`, `description`, `url`.' }, schemaFaker: { name: 'Enable Schema Faking', @@ -210,9 +211,9 @@ const optionIds = [ default: 'Schema', availableOptions: ['Example', 'Schema'], description: 'Select whether to generate the request and response parameters based on the' + - ' [schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject) or the' + - ' [example](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#exampleObject)' + - ' in the schema.', + ' [schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject) or the' + + ' [example](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#exampleObject)' + + ' in the schema.', external: true, usage: ['CONVERSION'] }, @@ -232,6 +233,16 @@ const optionIds = [ 'the collection.', external: true, usage: ['CONVERSION'] + }, preferredRequestBodyType: { + name: 'Select request body type', + type: 'enum', + default: 'first-listed', + availableOptions: ['x-www-form-urlencoded', 'form-data', 'raw', 'first-listed'], + description: 'When there are multiple content-types defined in the request body of OpenAPI, the conversion ' + + 'selects the preferred option content-type as request body.If "first-listed" is set, the first ' + + 'content-type defined in the OpenAPI spec will be selected.', + external: false, + usage: ['CONVERSION'] } }; @@ -312,8 +323,8 @@ describe('getOptions', function() { describe('OPTIONS.md', function() { it('must contain all details of options', function () { const optionsDoc = fs.readFileSync('OPTIONS.md', 'utf-8'), - v1Options = getOptions(undefined, { external: true, moduleVersion: 'v1' }), - v2Options = getOptions(undefined, { external: true, moduleVersion: 'v2' }), + v1Options = getOptions(undefined, { moduleVersion: 'v1' }), + v2Options = getOptions(undefined, { moduleVersion: 'v2' }), allOptions = _.uniqBy(_.concat(v1Options, v2Options), 'id'); expect(optionsDoc).to.eql(generateOptionsDoc(allOptions)); diff --git a/test/unit/convertV2.test.js b/test/unit/convertV2.test.js index 85c9d99f..985e3499 100644 --- a/test/unit/convertV2.test.js +++ b/test/unit/convertV2.test.js @@ -102,7 +102,26 @@ const expect = require('chai').expect, multiExampleRequestResponse = path.join(__dirname, VALID_OPENAPI_PATH, '/multiExampleRequestResponse.yaml'), multiExampleMatchingRequestResponse = - path.join(__dirname, VALID_OPENAPI_PATH, '/multiExampleMatchingRequestResponse.yaml'); + path.join(__dirname, VALID_OPENAPI_PATH, '/multiExampleMatchingRequestResponse.yaml'), + multiContentTypesMultiExample = + path.join(__dirname, VALID_OPENAPI_PATH, '/multiContentTypesMultiExample.json'), + multiExampleRequestVariousResponse = + path.join(__dirname, VALID_OPENAPI_PATH, '/multiExampleRequestVariousResponse.yaml'), + multiExampleResponseCodeMatching = + path.join(__dirname, VALID_OPENAPI_PATH, '/multiExampleResponseCodeMatching.json'), + duplicateCollectionVars = + path.join(__dirname, VALID_OPENAPI_PATH, '/duplicateCollectionVars.json'), + readOnlySpec = + path.join(__dirname, VALID_OPENAPI_PATH, '/readOnly.json'), + readOnlyRefSpec = + path.join(__dirname, VALID_OPENAPI_PATH, '/readOnlyRef.json'), + readOnlyAllOfSpec = + path.join(__dirname, VALID_OPENAPI_PATH, '/readOnlyAllOf.json'), + readOnlyOneOfSpec = + path.join(__dirname, VALID_OPENAPI_PATH, '/readOnlyOneOf.json'), + readOnlyNestedSpec = + path.join(__dirname, VALID_OPENAPI_PATH, '/readOnlyNested.json'), + issue795 = path.join(__dirname, VALID_OPENAPI_PATH, '/form-binary-file.json'); describe('The convert v2 Function', function() { @@ -2091,6 +2110,28 @@ describe('The convert v2 Function', function() { }); }); + it('[GITHUB #417] - should convert file with allOf schemas containing additionalProperties as false ', function() { + const fileSource = path.join(__dirname, VALID_OPENAPI_PATH, 'allOfAdditionalProperties.json'), + fileData = fs.readFileSync(fileSource, 'utf8'), + input = { + type: 'string', + data: fileData + }; + + Converter.convertV2(input, { + optimizeConversion: false + }, (err, result) => { + const expectedRequestBody = JSON.parse(result.output[0].data.item[0].item[0].item[0].request.body.raw); + + expect(err).to.be.null; + expect(result.result).to.be.true; + + expect(expectedRequestBody).to.be.an('object'); + expect(expectedRequestBody).to.have.keys(['phoneNumber', 'zipCode', 'countryCode', 'state', 'city', + 'address1', 'lastName', 'firstName', 'email', 'campaignId']); + }); + }); + it('Should convert a swagger document with XML example correctly', function(done) { const fileData = fs.readFileSync(path.join(__dirname, SWAGGER_20_FOLDER_YAML, 'xml_example.yaml'), 'utf8'), input = { @@ -2522,44 +2563,50 @@ describe('The convert v2 Function', function() { }); }); - it('both request and response body contains multiple examples with matching keys', function(done) { - var openapi = fs.readFileSync(multiExampleMatchingRequestResponse, 'utf8'); - Converter.convertV2({ type: 'string', data: openapi }, { parametersResolution: 'Example' }, - (err, conversionResult) => { - expect(err).to.be.null; - expect(conversionResult.result).to.equal(true); - expect(conversionResult.output.length).to.equal(1); - expect(conversionResult.output[0].type).to.equal('collection'); - expect(conversionResult.output[0].data).to.have.property('info'); - expect(conversionResult.output[0].data).to.have.property('item'); - expect(conversionResult.output[0].data.item[0].item.length).to.equal(1); + it('both request and response body contains multiple examples with matching keys and ignore non matching keys', + function(done) { + const openapi = fs.readFileSync(multiExampleMatchingRequestResponse, 'utf8'); + Converter.convertV2({ type: 'string', data: openapi }, { parametersResolution: 'Example' }, + (err, conversionResult) => { + expect(err).to.be.null; + expect(conversionResult.result).to.equal(true); + expect(conversionResult.output.length).to.equal(1); + expect(conversionResult.output[0].type).to.equal('collection'); + expect(conversionResult.output[0].data).to.have.property('info'); + expect(conversionResult.output[0].data).to.have.property('item'); + expect(conversionResult.output[0].data.item[0].item.length).to.equal(1); - const item = conversionResult.output[0].data.item[0].item[0]; + const item = conversionResult.output[0].data.item[0].item[0]; - expect(JSON.parse(item.request.body.raw)).to.eql({ - includedFields: ['user', 'height', 'weight'] - }); - expect(item.response).to.have.lengthOf(2); - expect(item.response[0].name).to.eql('Complete request'); - expect(item.response[0]._postman_previewlanguage).to.eql('json'); - expect(JSON.parse(item.response[0].body)).to.eql({ - user: 1, - height: 168, - weight: 44 - }); - expect(JSON.parse(item.response[0].originalRequest.body.raw)).to.eql({ - includedFields: ['user', 'height', 'weight'] - }); + expect(JSON.parse(item.request.body.raw)).to.eql({ + includedFields: ['user', 'height', 'weight'] + }); - expect(item.response[1].name).to.eql('Request with only required params'); - expect(item.response[1]._postman_previewlanguage).to.eql('json'); - expect(JSON.parse(item.response[1].body)).to.eql({ user: 1 }); - expect(JSON.parse(item.response[1].originalRequest.body.raw)).to.eql({ - includedFields: ['user'] + /** + * Even though both req and res has 3 examples, only 2 example should be present + * as only 2 examples have matching keys + */ + expect(item.response).to.have.lengthOf(2); + expect(item.response[0].name).to.eql('Complete request'); + expect(item.response[0]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[0].body)).to.eql({ + user: 1, + height: 168, + weight: 44 + }); + expect(JSON.parse(item.response[0].originalRequest.body.raw)).to.eql({ + includedFields: ['user', 'height', 'weight'] + }); + + expect(item.response[1].name).to.eql('Request with only required params'); + expect(item.response[1]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[1].body)).to.eql({ user: 1 }); + expect(JSON.parse(item.response[1].originalRequest.body.raw)).to.eql({ + includedFields: ['user'] + }); + done(); }); - done(); - }); - }); + }); it('both request and response body contains multiple examples in mentioned order when no matching keys', function(done) { @@ -2602,5 +2649,305 @@ describe('The convert v2 Function', function() { done(); }); }); + + it('request body and responses contain multiple content types and multiple examples', function(done) { + const openapi = fs.readFileSync(multiContentTypesMultiExample, 'utf8'); + Converter.convertV2({ type: 'string', data: openapi }, { parametersResolution: 'Example' }, + (err, conversionResult) => { + expect(err).to.be.null; + expect(conversionResult.result).to.equal(true); + expect(conversionResult.output.length).to.equal(1); + expect(conversionResult.output[0].type).to.equal('collection'); + expect(conversionResult.output[0].data).to.have.property('info'); + expect(conversionResult.output[0].data).to.have.property('item'); + expect(conversionResult.output[0].data.item[0].item.length).to.equal(1); + + const item = conversionResult.output[0].data.item[0].item[0], + okReqExample = { message: 'ok' }, + failReqExample = { message: 'fail' }, + okResExample = { message: 'Found', code: 200123 }, + failResExample = { message: 'Not Found' }; + + expect(JSON.parse(item.request.body.raw)).to.eql(okReqExample); + expect(item.response).to.have.lengthOf(4); + expect(item.response[0].name).to.eql('ok_example'); + expect(item.response[0].code).to.eql(200); + expect(item.response[0]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[0].originalRequest.body.raw)).to.eql(okReqExample); + expect(JSON.parse(item.response[0].body)).to.eql(okResExample); + + expect(item.response[1].name).to.eql('not_ok_example'); + expect(item.response[1].code).to.eql(200); + expect(item.response[1]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[1].originalRequest.body.raw)).to.eql(failReqExample); + expect(JSON.parse(item.response[1].body)).to.eql(okResExample); + + expect(item.response[2].name).to.eql('ok_example'); + expect(item.response[2].code).to.eql(500); + expect(item.response[2]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[2].originalRequest.body.raw)).to.eql(okReqExample); + expect(JSON.parse(item.response[2].body)).to.eql(failResExample); + + expect(item.response[3].name).to.eql('not_ok_example'); + expect(item.response[3].code).to.eql(500); + expect(item.response[3]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[3].originalRequest.body.raw)).to.eql(failReqExample); + expect(JSON.parse(item.response[3].body)).to.eql(failResExample); + done(); + }); + }); + + it('request body and responses contain multiple examples with various response code ' + + 'having single or multiple examples', function(done) { + const openapi = fs.readFileSync(multiExampleRequestVariousResponse, 'utf8'); + Converter.convertV2({ type: 'string', data: openapi }, { parametersResolution: 'Example' }, + (err, conversionResult) => { + expect(err).to.be.null; + expect(conversionResult.result).to.equal(true); + expect(conversionResult.output.length).to.equal(1); + expect(conversionResult.output[0].type).to.equal('collection'); + expect(conversionResult.output[0].data).to.have.property('info'); + expect(conversionResult.output[0].data).to.have.property('item'); + expect(conversionResult.output[0].data.item[0].item.length).to.equal(1); + + const item = conversionResult.output[0].data.item[0].item[0], + reqBody1 = { includedFields: ['user', 'height', 'weight'] }, + reqBody2 = { includedFields: ['eyeColor'] }; + + expect(JSON.parse(item.request.body.raw)).to.eql(reqBody1); + expect(item.response).to.have.lengthOf(4); + expect(item.response[0].name).to.eql('Request with only required params'); + expect(item.response[0].code).to.eql(200); + expect(item.response[0]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[0].originalRequest.body.raw)).to.eql(reqBody1); + expect(JSON.parse(item.response[0].body)).to.eql({ user: 1 }); + + expect(item.response[1].name).to.eql('Request with only bad params'); + expect(item.response[1].code).to.eql(400); + expect(item.response[1]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[1].originalRequest.body.raw)).to.eql(reqBody2); + expect(JSON.parse(item.response[1].body)).to.eql({ eyeColor: 'gray' }); + + expect(item.response[2].name).to.eql('Failed request - Negative user'); + expect(item.response[2].code).to.eql(500); + expect(item.response[2]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[2].originalRequest.body.raw)).to.eql(reqBody1); + expect(JSON.parse(item.response[2].body)).to.eql({ user: -99 }); + + expect(item.response[3].name).to.eql('Failed request - All negatives'); + expect(item.response[3].code).to.eql(500); + expect(item.response[3]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[3].originalRequest.body.raw)).to.eql(reqBody2); + expect(JSON.parse(item.response[3].body)).to.eql({ user: -999, height: -168, weight: -44 }); + done(); + }); + }); + + it('request body and responses contain examples with matching keys as response codes', function(done) { + const openapi = fs.readFileSync(multiExampleResponseCodeMatching, 'utf8'); + Converter.convertV2({ type: 'string', data: openapi }, { parametersResolution: 'Example' }, + (err, conversionResult) => { + expect(err).to.be.null; + expect(conversionResult.result).to.equal(true); + expect(conversionResult.output.length).to.equal(1); + expect(conversionResult.output[0].type).to.equal('collection'); + expect(conversionResult.output[0].data).to.have.property('info'); + expect(conversionResult.output[0].data).to.have.property('item'); + expect(conversionResult.output[0].data.item[0].item.length).to.equal(1); + + const item = conversionResult.output[0].data.item[0].item[0], + defaultReqBody = { + userDetail: { + roleId: 1, + department: 'Admin 1', + email: '123@gmail.com' + } + }; + + expect(JSON.parse(item.request.body.raw)).to.eql(defaultReqBody); + expect(item.response).to.have.lengthOf(5); + + expect(item.response[0].name).to.eql('200'); + expect(item.response[0].code).to.eql(200); + expect(item.response[0]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[0].originalRequest.body.raw)).to.eql(defaultReqBody); + expect(JSON.parse(item.response[0].body)).to.eql({ userId: 12 }); + + expect(item.response[1].name).to.eql('400'); + expect(item.response[1].code).to.eql(400); + expect(item.response[1]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[1].originalRequest.body.raw)).to.eql({ + userDetail: { roleId: null, department: 'Admin 1', email: '' } + }); + expect(JSON.parse(item.response[1].body)).to.eql({ + hasErrorMessage: true, + errorMessage: 'Bad Request', + validationsErrors: [{ propertyName: 'RoleID', errorMessage: 'Can not be null' }] + }); + + expect(item.response[2].name).to.eql('404'); + expect(item.response[2].code).to.eql(404); + expect(item.response[2]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[2].originalRequest.body.raw)).to.eql({ + userDetail: { roleId: 0, department: 'Admin 0', email: '123@gmail.com' } + }); + expect(JSON.parse(item.response[2].body)).to.eql({ message: 'AddUserDetailsCommand : User Role Not Found' }); + + expect(item.response[3].name).to.eql('409'); + expect(item.response[3].code).to.eql(409); + expect(item.response[3]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[3].originalRequest.body.raw)).to.eql({ + userDetail: { roleId: 1, department: 'Admin 1', email: '123@gmail.com' } + }); + expect(JSON.parse(item.response[3].body)).to.eql({ message: 'AddUserDetailsCommand : Duplicate' }); + + expect(item.response[4].name).to.eql('500'); + expect(item.response[4].code).to.eql(500); + expect(item.response[4]._postman_previewlanguage).to.eql('json'); + expect(JSON.parse(item.response[4].originalRequest.body.raw)).to.eql({ + userDetail: { roleId: 0, department: null, email: null } + }); + expect(JSON.parse(item.response[4].body)).to.eql({ message: 'AddUserDetailsCommand : System Error message' }); + done(); + }); + }); + }); + + it('[Github #11884] Should not contain duplicate variables created from requests path', function (done) { + const openapi = fs.readFileSync(duplicateCollectionVars, 'utf8'); + Converter.convertV2({ type: 'string', data: openapi }, { parametersResolution: 'Example' }, + (err, conversionResult) => { + expect(err).to.be.null; + expect(conversionResult.result).to.equal(true); + expect(conversionResult.output.length).to.equal(1); + expect(conversionResult.output[0].type).to.equal('collection'); + expect(conversionResult.output[0].data).to.have.property('info'); + expect(conversionResult.output[0].data).to.have.property('item'); + expect(conversionResult.output[0].data).to.have.property('variable'); + expect(conversionResult.output[0].data.variable).to.have.lengthOf(2); + expect(conversionResult.output[0].data.variable[0]).to.have.property('key', 'baseUrl'); + expect(conversionResult.output[0].data.variable[1]).to.have.property('key', 'MyParam'); + done(); + }); + }); + + describe('[Github #12255] Should handle readOnly and writeOnly correctly', function() { + it('when definition contains inline schemas', function(done) { + var openapi = fs.readFileSync(readOnlySpec, 'utf8'), + options = { schemaFaker: true, exampleParametersResolution: 'schema' }; + Converter.convert({ type: 'string', data: openapi }, options, (err, conversionResult) => { + let requestBody = JSON.parse(conversionResult.output[0].data.item[0].item[1].request.body.raw), + responseBody = JSON.parse(conversionResult.output[0].data.item[0].item[0].response[0].body); + expect(err).to.be.null; + expect(requestBody).to.eql({ name: '', tag: '' }); + expect(responseBody).to.eql([ + { id: '', name: '' }, + { id: '', name: '' } + ]); + done(); + }); + }); + + it('when definition contains $ref in schemas', function(done) { + var openapi = fs.readFileSync(readOnlyRefSpec, 'utf8'), + options = { schemaFaker: true, exampleParametersResolution: 'schema' }; + Converter.convert({ type: 'string', data: openapi }, options, (err, conversionResult) => { + let requestBody = JSON.parse(conversionResult.output[0].data.item[0].item[1].request.body.raw), + responseBody = JSON.parse(conversionResult.output[0].data.item[0].item[0].response[0].body); + expect(err).to.be.null; + expect(requestBody).to.eql({ name: '', tag: '' }); + expect(responseBody).to.eql([ + { id: '', name: '' }, + { id: '', name: '' } + ]); + done(); + }); + }); + + it('when definition contains composite keyword "allOf" in schema', function(done) { + var openapi = fs.readFileSync(readOnlyAllOfSpec, 'utf8'), + options = { schemaFaker: true, exampleParametersResolution: 'schema' }; + Converter.convert({ type: 'string', data: openapi }, options, (err, conversionResult) => { + let requestBody = JSON.parse(conversionResult.output[0].data.item[0].item[1].request.body.raw), + responseBody = JSON.parse(conversionResult.output[0].data.item[0].item[0].response[0].body); + expect(err).to.be.null; + expect(requestBody).to.eql({ + name: '', tag: '', + 'user.name': '', 'user.tag': '' + }); + expect(responseBody).to.eql([ + { id: '', name: '', 'user.id': '', 'user.name': '' }, + { id: '', name: '', 'user.id': '', 'user.name': '' } + ]); + done(); + }); + }); + + it('when definition contains composite keyword "oneOf" in schema', function(done) { + var openapi = fs.readFileSync(readOnlyOneOfSpec, 'utf8'), + options = { schemaFaker: true, exampleParametersResolution: 'schema' }; + Converter.convert({ type: 'string', data: openapi }, options, (err, conversionResult) => { + let requestBody = JSON.parse(conversionResult.output[0].data.item[0].item[1].request.body.raw), + responseBody = JSON.parse(conversionResult.output[0].data.item[0].item[0].response[0].body); + expect(err).to.be.null; + expect(requestBody).to.eql({ + 'user/name': '', 'user/tag': '' + }); + expect(responseBody).to.eql([ + { 'user/id': '', 'user/name': '' }, + { 'user/id': '', 'user/name': '' } + ]); + done(); + }); + }); + + it('when definition contains schemas with nested array and object schema types', function(done) { + var openapi = fs.readFileSync(readOnlyNestedSpec, 'utf8'), + options = { schemaFaker: true, exampleParametersResolution: 'schema' }; + Converter.convert({ type: 'string', data: openapi }, options, (err, conversionResult) => { + let requestBody = JSON.parse(conversionResult.output[0].data.item[0].item[1].request.body.raw), + responseBody = JSON.parse(conversionResult.output[0].data.item[0].item[0].response[0].body); + expect(err).to.be.null; + + // Assert readOnly property to not be in request body and other/writeOnly properties to be in response body + expect(requestBody).to.not.have.property('id'); + expect(requestBody).to.have.property('name', ''); + expect(requestBody).to.have.property('tag', ''); + expect(requestBody.address).to.not.have.property('addressCode'); + expect(requestBody.address).to.have.property('city', ''); + expect(requestBody.address).to.have.property('state', ''); + + // Assert writeOnly property to not be in request body and other/readOnly properties to be in response body + expect(responseBody).to.have.property('name', ''); + expect(responseBody.pet).to.have.property('id', ''); + expect(responseBody.pet).to.have.property('name', ''); + expect(responseBody.pet).to.not.have.property('tag', ''); + expect(responseBody.pet.address).to.have.property('addressCode'); + expect(responseBody.pet.address).to.have.property('city', ''); + expect(responseBody.pet.address).to.not.have.property('state', ''); + done(); + }); + }); + }); + + it('[Github #795] Should properly convert format binary to form data', function (done) { + var openapi = fs.readFileSync(issue795, 'utf8'), + reqBody, formData; + Converter.convertV2({ type: 'string', data: openapi }, { + requestNameSource: 'Fallback', + indentCharacter: 'Space', + collapseFolders: true, + optimizeConversion: true, + parametersResolution: 'schema' + }, (err, conversionResult) => { + + reqBody = conversionResult.output[0].data.item[0].item[0].request.body; + formData = reqBody.formdata[0]; + + expect(err).to.be.null; + expect(conversionResult.result).to.equal(true); + expect(formData.type).to.equal('file'); + done(); + }); }); }); diff --git a/test/unit/deref.test.js b/test/unit/deref.test.js index 9d99a5b0..c318ebd9 100644 --- a/test/unit/deref.test.js +++ b/test/unit/deref.test.js @@ -516,6 +516,76 @@ describe('DEREF FUNCTION TESTS ', function() { }); done(); }); + + it('should resolve schemas under allOf keyword with additionalProperties set to false correctly', function (done) { + var schema = { + 'allOf': [ + { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'source': { + 'type': 'string', + 'format': 'uuid' + }, + 'status': { + 'type': 'string', + 'enum': ['incomplete', 'completed', 'refunded'] + }, + 'actionId': { 'type': 'integer', 'minimum': 5 }, + 'result': { 'type': 'object' } + }, + 'required': ['source', 'actionId', 'result'] + }, + { + 'additionalProperties': false, + 'properties': { + 'result': { + 'type': 'object', + 'properties': { + 'err': { 'type': 'string' }, + 'data': { 'type': 'object' } + } + }, + 'status': { + 'type': 'string', + 'enum': ['no_market', 'too_small', 'too_large'] + } + } + } + ] + }; + + expect(deref.resolveAllOf( + schema, + 'REQUEST', + { concreteUtils: schemaUtils30X }, + { resolveTo: 'example' } + )).to.deep.equal({ + type: 'object', + additionalProperties: false, + required: ['source', 'actionId', 'result'], + properties: { + source: { + type: 'string', + format: 'uuid' + }, + status: { + type: 'string', + enum: ['incomplete', 'completed', 'refunded', 'no_market', 'too_small', 'too_large'] + }, + actionId: { 'type': 'integer', 'minimum': 5 }, + result: { + type: 'object', + properties: { + err: { 'type': 'string' }, + data: { 'type': 'object' } + } + } + } + }); + done(); + }); }); describe('_getEscaped should', function() { diff --git a/test/unit/faker.test.js b/test/unit/faker.test.js index 465e4ae6..4e326c21 100644 --- a/test/unit/faker.test.js +++ b/test/unit/faker.test.js @@ -13,7 +13,8 @@ describe('JSON SCHEMA FAKER TESTS', function () { useDefaultValue: true, useExamplesValue: true, ignoreMissingRefs: true, - avoidExampleItemsLength: false + avoidExampleItemsLength: false, + failOnInvalidFormat: false }); }); @@ -27,7 +28,8 @@ describe('JSON SCHEMA FAKER TESTS', function () { maxItems: 20, useDefaultValue: true, ignoreMissingRefs: true, - avoidExampleItemsLength: true + avoidExampleItemsLength: true, + failOnInvalidFormat: false }); }); diff --git a/test/unit/schemaUtilsV2.test.js b/test/unit/schemaUtilsV2.test.js new file mode 100644 index 00000000..54792553 --- /dev/null +++ b/test/unit/schemaUtilsV2.test.js @@ -0,0 +1,184 @@ +const { + resolveRequestBodyForPostmanRequest + } = require('../../libV2/schemaUtils.js'), + concreteUtils = require('../../lib/30XUtils/schemaUtils30X'), + expect = require('chai').expect, + + // Example operationItem + operationItem = { + put: { + 'tags': [ + 'Administration: Users' + ], + 'summary': 'Create or Update User', + 'operationId': 'User', + 'requestBody': { + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string' + } + } + } + }, + 'text/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string' + } + } + } + }, + 'application/xml': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string' + } + } + } + }, + 'multipart/form-data': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string' + } + } + } + }, + 'application/x-www-form-urlencoded': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string' + } + } + } + } + }, + 'description': 'The User request.', + 'required': true + }, + 'responses': { + '200': { + 'description': 'OK', + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': {} + } + } + } + } + } + } + }; + +describe('resolveRequestBodyForPostmanRequest function', function () { + + it('should return first-listed request body when preferredRequestBodyType is not set', function () { + const contextTest = { + concreteUtils, + schemaCache: {}, + schemaFakerCache: {}, + computedOptions: { + parametersResolution: 'schema' + } + }, + operationItemTest = operationItem.put, + result = resolveRequestBodyForPostmanRequest(contextTest, operationItemTest); + + expect(result.body.mode).to.equal('raw'); + }); + + it('should return first-listed request body when preferredRequestBodyType is not a valid option', function () { + const contextTest = { + concreteUtils, + schemaCache: {}, + schemaFakerCache: {}, + computedOptions: { + parametersResolution: 'schema', + preferredRequestBodyType: 'foo-bar' + } + }, + operationItemTest = operationItem.put, + result = resolveRequestBodyForPostmanRequest(contextTest, operationItemTest); + + expect(result.body.mode).to.equal('raw'); + }); + + it('should return encoded request body when preferredRequestBodyType is x-www-form-urlencoded', function () { + const contextTest = { + concreteUtils, + schemaCache: {}, + schemaFakerCache: {}, + computedOptions: { + parametersResolution: 'schema', + preferredRequestBodyType: 'x-www-form-urlencoded' + } + }, + operationItemTest = operationItem.put, + result = resolveRequestBodyForPostmanRequest(contextTest, operationItemTest); + + expect(result.body.mode).to.equal('urlencoded'); + }); + + it('should return form data request body when preferredRequestBodyType is form-data', function () { + const contextTest = { + concreteUtils, + schemaCache: {}, + schemaFakerCache: {}, + computedOptions: { + parametersResolution: 'schema', + preferredRequestBodyType: 'form-data' + } + }, + operationItemTest = operationItem.put, + result = resolveRequestBodyForPostmanRequest(contextTest, operationItemTest); + + expect(result.body.mode).to.equal('formdata'); + }); + + it('should return raw request body when preferredRequestBodyType is raw', function () { + const contextTest = { + concreteUtils, + schemaCache: {}, + schemaFakerCache: {}, + computedOptions: { + parametersResolution: 'schema', + preferredRequestBodyType: 'raw' + } + }, + operationItemTest = operationItem.put, + result = resolveRequestBodyForPostmanRequest(contextTest, operationItemTest); + + expect(result.body.mode).to.equal('raw'); + }); + + it('should return raw request body when preferredRequestBodyType is first-listed', function () { + const contextTest = { + concreteUtils, + schemaCache: {}, + schemaFakerCache: {}, + computedOptions: { + parametersResolution: 'schema', + preferredRequestBodyType: 'first-listed' + } + }, + operationItemTest = operationItem.put, + result = resolveRequestBodyForPostmanRequest(contextTest, operationItemTest); + + expect(result.body.mode).to.equal('raw'); + }); + +});