UNPKG

openapi-to-postmanv2

Version:

Convert a given OpenAPI specification to Postman Collection v2.0

1,387 lines (1,202 loc) 91.7 kB
const generateAuthForCollectionFromOpenAPI = require('./helpers/collection/generateAuthForCollectionFromOpenAPI'); const utils = require('./utils'); const schemaFaker = require('../assets/json-schema-faker'), _ = require('lodash'), mergeAllOf = require('json-schema-merge-allof'), xmlFaker = require('./xmlSchemaFaker.js'), URLENCODED = 'application/x-www-form-urlencoded', APP_JSON = 'application/json', APP_JS = 'application/javascript', TEXT_XML = 'text/xml', APP_XML = 'application/xml', TEXT_PLAIN = 'text/plain', TEXT_HTML = 'text/html', FORM_DATA = 'multipart/form-data', HEADER_TYPE = { JSON: 'json', XML: 'xml', INVALID: 'invalid' }, HEADER_TYPE_PREVIEW_LANGUAGE_MAP = { [HEADER_TYPE.JSON]: 'json', [HEADER_TYPE.XML]: 'xml' }, // These headers are to be validated explicitly // As these are not defined under usual parameters object and need special handling IMPLICIT_HEADERS = [ 'content-type', // 'content-type' is defined based on content/media-type of req/res body, 'accept', 'authorization' ], SCHEMA_PROPERTIES_TO_EXCLUDE = [ 'default', 'enum', 'pattern' ], // All formats supported by both ajv and json-schema-faker SUPPORTED_FORMATS = [ 'date', 'time', 'date-time', 'uri', 'uri-reference', 'uri-template', 'email', 'hostname', 'ipv4', 'ipv6', 'regex', 'uuid', 'binary', 'json-pointer', 'int64', 'float', 'double' ], typesMap = { integer: { int32: '<integer>', int64: '<long>' }, number: { float: '<float>', double: '<double>' }, string: { byte: '<byte>', binary: '<binary>', date: '<date>', 'date-time': '<dateTime>', password: '<password>' }, boolean: '<boolean>', array: '<array>', object: '<object>' }, // Maximum size of schema till whch we generate 2 elements per array (50 KB) SCHEMA_SIZE_OPTIMIZATION_THRESHOLD = 50 * 1024, PROPERTIES_TO_ASSIGN_ON_CASCADE = ['type', 'nullable', 'properties'], crypto = require('crypto'), /** * @param {*} rootObject - the object from which you're trying to read a property * @param {*} pathArray - each element in this array a property of the previous object * @param {*} defValue - what to return if the required path is not found * @returns {*} - required property value * @description - this is similar to _.get(rootObject, pathArray.join('.')), but also works for cases where * there's a . in the property name */ _getEscaped = (rootObject, pathArray, defValue) => { if (!(pathArray instanceof Array)) { return null; } if (rootObject === undefined) { return defValue; } if (_.isEmpty(pathArray)) { return rootObject; } return _getEscaped(rootObject[pathArray.shift()], pathArray, defValue); }, getXmlVersionContent = (bodyContent) => { const regExp = new RegExp('([<\\?xml]+[\\s{1,}]+[version="\\d.\\d"]+[\\sencoding="]+.{1,15}"\\?>)'); let xmlBody = bodyContent; if (_.isFunction(bodyContent.match) && !bodyContent.match(regExp)) { const versionContent = '<?xml version="1.0" encoding="UTF-8"?>\n'; xmlBody = versionContent + xmlBody; } return xmlBody; }; // See https://github.com/json-schema-faker/json-schema-faker/tree/master/docs#available-options schemaFaker.option({ requiredOnly: false, optionalsProbability: 1.0, // always add optional fields maxLength: 256, minItems: 1, // for arrays maxItems: 20, // limit on maximum number of items faked for (type: arrray) useDefaultValue: true, ignoreMissingRefs: true, avoidExampleItemsLength: true, // option to avoid validating type array schema example's minItems and maxItems props. failOnInvalidFormat: false }); let QUERYPARAM = 'query', CONVERSION = 'conversion', HEADER = 'header', PATHPARAM = 'path', SCHEMA_TYPES = { array: 'array', boolean: 'boolean', integer: 'integer', number: 'number', object: 'object', string: 'string' }, SCHEMA_FORMATS = { DEFAULT: 'default', // used for non-request-body data and json XML: 'xml' // used for request-body XMLs }, REF_STACK_LIMIT = 30, ERR_TOO_MANY_LEVELS = '<Error: Too many levels of nesting to fake this schema>', /** * Changes the {} around scheme and path variables to :variable * @param {string} url - the url string * @returns {string} string after replacing /{pet}/ with /:pet/ */ sanitizeUrl = (url) => { // URL should always be string so update value if non-string value is found if (typeof url !== 'string') { return ''; } // This simply replaces all instances of {text} with {{text}} // text cannot have any of these 3 chars: /{} // {{text}} will not be converted const replacer = function (match, p1, offset, string) { if (string[offset - 1] === '{' && string[offset + match.length + 1] !== '}') { return match; } return '{' + p1 + '}'; }; url = _.isString(url) ? url.replace(/(\{[^\/\{\}]+\})/g, replacer) : ''; // converts the following: // /{{path}}/{{file}}.{{format}}/{{hello}} => /:path/{{file}}.{{format}}/:hello let pathVariables = url.match(/(\/\{\{[^\/\{\}]+\}\})(?=\/|$)/g); if (pathVariables) { pathVariables.forEach((match) => { const replaceWith = match.replace(/{{/g, ':').replace(/}}/g, ''); url = url.replace(match, replaceWith); }); } return url; }, filterCollectionAndPathVariables = (url, pathVariables) => { // URL should always be string so update value if non-string value is found if (typeof url !== 'string') { return ''; } // /:path/{{file}}.{{format}}/:hello => only {{file}} and {{format}} will match let variables = url.match(/(\{\{[^\/\{\}]+\}\})/g), collectionVariables = [], collectionVariableMap = {}, filteredPathVariables = []; _.forEach(variables, (variable) => { const collVar = variable.replace(/{{/g, '').replace(/}}/g, ''); collectionVariableMap[collVar] = true; }); // Filter out variables that are to be added as collection variables _.forEach(pathVariables, (pathVariable) => { if (collectionVariableMap[pathVariable.key]) { collectionVariables.push(_.pick(pathVariable, ['key', 'value'])); } else { filteredPathVariables.push(pathVariable); } }); return { collectionVariables, pathVariables: filteredPathVariables }; }, resolveBaseUrlForPostmanRequest = (operationItem) => { let serverObj = _.get(operationItem, 'servers.0'), baseUrl = '{{baseUrl}}', serverVariables = [], pathVariables = [], collectionVariables = []; if (!serverObj) { return { collectionVariables, pathVariables, baseUrl }; } baseUrl = sanitizeUrl(serverObj.url); _.forOwn(serverObj.variables, (value, key) => { serverVariables.push({ key, value: _.get(value, 'default') || '' }); }); ({ collectionVariables, pathVariables } = filterCollectionAndPathVariables(baseUrl, serverVariables)); return { collectionVariables, pathVariables, baseUrl }; }, /** * Provides ref stack limit for current instance * @param {*} stackLimit - Defined stackLimit in options * * @returns {Number} Returns the stackLimit to be used */ getRefStackLimit = (stackLimit) => { if (typeof stackLimit === 'number' && stackLimit > REF_STACK_LIMIT) { return stackLimit; } 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 * @param {Object} $ref - Ref that is to be resolved * @param {Number} stackDepth - Depth of the current stack for Ref resolution * @param {Object} seenRef - Seen Reference map * * @returns {Object} Returns the object that satisfies the schema */ resolveRefFromSchema = (context, $ref, stackDepth = 0, seenRef = {}) => { const { specComponents } = context, { stackLimit } = context.computedOptions; if (stackDepth >= getRefStackLimit(stackLimit)) { return { value: ERR_TOO_MANY_LEVELS }; } stackDepth++; seenRef[$ref] = true; if (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)) { return { value: `reference ${$ref} not found in the OpenAPI spec` }; } let splitRef = $ref.split('/'), resolvedSchema; // .split should return [#, components, schemas, schemaName] // So length should atleast be 4 if (splitRef.length < 4) { // not throwing an error. We didn't find the reference - generate a dummy value return { value: `reference ${$ref} not found in the OpenAPI spec` }; } // something like #/components/schemas/PaginationEnvelope/properties/page // will be resolved - we don't care about anything before the components part // splitRef.slice(1) will return ['components', 'schemas', 'PaginationEnvelope', 'properties', 'page'] // not using _.get here because that fails if there's a . in the property name (Pagination.Envelope, for example) splitRef = splitRef.slice(1).map((elem) => { // https://swagger.io/docs/specification/using-ref#escape // since / is the default delimiter, slashes are escaped with ~1 return decodeURIComponent( elem .replace(/~1/g, '/') .replace(/~0/g, '~') ); }); resolvedSchema = _getEscaped(specComponents, splitRef); if (resolvedSchema === undefined) { return { value: 'reference ' + $ref + ' not found in the OpenAPI spec' }; } if (_.get(resolvedSchema, '$ref')) { if (seenRef[resolvedSchema.$ref]) { return { value: '<Circular reference to ' + resolvedSchema.$ref + ' detected>' }; } return resolveRefFromSchema(context, resolvedSchema.$ref, stackDepth, _.cloneDeep(seenRef)); } return resolvedSchema; }, /** * Resolve a given ref from an example * @param {Object} context - Global context object * @param {Object} $ref - Ref that is to be resolved * @param {Number} stackDepth - Depth of the current stack for Ref resolution * @param {Object} seenRef - Seen Reference map * * @returns {Object} Returns the object that satisfies the schema */ resolveRefForExamples = (context, $ref, stackDepth = 0, seenRef = {}) => { const { specComponents } = context, { stackLimit } = context.computedOptions; if (stackDepth >= getRefStackLimit(stackLimit)) { return { value: ERR_TOO_MANY_LEVELS }; } stackDepth++; seenRef[$ref] = true; if (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)) { return { value: `reference ${schema.$ref} not found in the OpenAPI spec` }; } let splitRef = $ref.split('/'), resolvedExample; // .split should return [#, components, schemas, schemaName] // So length should atleast be 4 if (splitRef.length < 4) { // not throwing an error. We didn't find the reference - generate a dummy value return { value: `reference ${$ref} not found in the OpenAPI spec` }; } // something like #/components/schemas/PaginationEnvelope/properties/page // will be resolved - we don't care about anything before the components part // splitRef.slice(1) will return ['components', 'schemas', 'PaginationEnvelope', 'properties', 'page'] // not using _.get here because that fails if there's a . in the property name (Pagination.Envelope, for example) splitRef = splitRef.slice(1).map((elem) => { // https://swagger.io/docs/specification/using-ref#escape // since / is the default delimiter, slashes are escaped with ~1 return decodeURIComponent( elem .replace(/~1/g, '/') .replace(/~0/g, '~') ); }); resolvedExample = _getEscaped(specComponents, splitRef); if (resolvedExample === undefined) { return { value: 'reference ' + $ref + ' not found in the OpenAPI spec' }; } if (_.has(resolvedExample, '$ref')) { if (seenRef[resolvedExample.$ref]) { return { value: `<Circular reference to ${resolvedExample.$ref} detected>` }; } return resolveRefFromSchema(context, resolvedExample.$ref, stackDepth, _.cloneDeep(seenRef)); } // Add the resolved schema to the global schema cache context.schemaCache[$ref] = { schema: resolvedExample, readOnlyPropCache: {}, writeOnlyPropCache: {} }; return resolvedExample; }, resolveExampleData = (context, exampleData) => { if (_.has(exampleData, '$ref')) { const resolvedRef = resolveRefForExamples(context, exampleData.$ref); exampleData = resolveExampleData(context, resolvedRef); } else if (typeof exampleData === 'object') { _.forOwn(exampleData, (data, key) => { exampleData[key] = resolveExampleData(context, data); }); } return exampleData; }, /** * returns first example in the input map * @param {Object} context - Global context object * @param {Object} exampleObj - Object defined in the schema * @returns {*} first example in the input map type */ getExampleData = (context, exampleObj) => { let example = {}, exampleKey; if (!exampleObj || typeof exampleObj !== 'object') { return ''; } exampleKey = Object.keys(exampleObj)[0]; example = exampleObj[exampleKey]; if (example && example.$ref) { example = resolveExampleData(context, example); } if (_.get(example, 'value')) { example = resolveExampleData(context, example.value); } return example; }, /** * 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 = 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), 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]; }, enum: (values) => { return _.uniq(_.concat(...values)); } } }); } catch (e) { console.warn('Error while resolving allOf schema: ', e); return { value: '<Error: Could not resolve allOf schema' }; } }, /** * Resolve a given ref from the schema * * @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 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 satisfies the schema */ _resolveSchema = (context, schema, stack = 0, resolveFor = CONVERSION, seenRef = {}, currentPath = '') => { if (!schema) { return new Error('Schema is empty'); } const { stackLimit } = context.computedOptions; if (stack >= getRefStackLimit(stackLimit)) { return { value: ERR_TOO_MANY_LEVELS }; } stack++; // eslint-disable-next-line one-var const compositeKeyword = schema.anyOf ? 'anyOf' : 'oneOf', { concreteUtils } = context; let compositeSchema = schema.anyOf || schema.oneOf; if (compositeSchema) { compositeSchema = _.map(compositeSchema, (schemaElement) => { const isSchemaFullyResolved = _.get(schemaElement, 'value') === ERR_TOO_MANY_LEVELS && !_.startsWith(_.get(schemaElement, 'value', ''), '<Circular reference to '); /** * elements of composite schema may not have resolved fully, * we want to avoid assigning these properties to schema element in such cases */ PROPERTIES_TO_ASSIGN_ON_CASCADE.forEach((prop) => { if (_.isNil(schemaElement[prop]) && !_.isNil(schema[prop]) && isSchemaFullyResolved) { schemaElement[prop] = schema[prop]; } }); return schemaElement; }); if (resolveFor === CONVERSION) { return _resolveSchema(context, compositeSchema[0], stack, resolveFor, _.cloneDeep(seenRef), currentPath); } 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), currentPath); } if (schema.$ref) { const schemaRef = schema.$ref; if (seenRef[schemaRef]) { return { value: '<Circular reference to ' + schemaRef + ' detected>' }; } seenRef[schemaRef] = true; if (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)); /** * 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, 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; } // Discard format if not supported by both json-schema-faker and ajv or pattern is also defined if (!_.includes(SUPPORTED_FORMATS, schema.format) || (schema.pattern && schema.format)) { schema.format && (delete schema.format); } if ( concreteUtils.compareTypes(schema.type, SCHEMA_TYPES.object) || schema.hasOwnProperty('properties') ) { let resolvedSchemaProps = {}, { includeDeprecated } = context.computedOptions; _.forOwn(schema.properties, (property, propertyName) => { // Skip property resolution if it's not schema object if (!_.isObject(property)) { return; } if ( property.format === 'decimal' || property.format === 'byte' || property.format === 'password' || property.format === 'unix-time' ) { delete property.format; } // Skip addition of deprecated properties based on provided options if (!includeDeprecated && property.deprecated) { return; } const currentPropPath = utils.addToJsonPath(currentPath, ['properties', propertyName]); resolvedSchemaProps[propertyName] = _resolveSchema(context, property, stack, resolveFor, _.cloneDeep(seenRef), currentPropPath); }); schema.properties = resolvedSchemaProps; schema.type = schema.type || SCHEMA_TYPES.object; } // 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), 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); })) { if (schema.hasOwnProperty('type')) { let { parametersResolution } = context.computedOptions; // Override default value to schema for CONVERSION only for parmeter resolution set to schema if (resolveFor === CONVERSION && parametersResolution === 'schema') { if (!schema.hasOwnProperty('format')) { schema.default = '<' + schema.type + '>'; } else if (typesMap.hasOwnProperty(schema.type)) { schema.default = typesMap[schema.type][schema.format]; // in case the format is a custom format (email, hostname etc.) // https://swagger.io/docs/specification/data-models/data-types/#string // eslint-disable-next-line max-depth if (!schema.default && schema.format) { // Use non defined format only for schema of type string schema.default = '<' + (schema.type === SCHEMA_TYPES.string ? schema.format : schema.type) + '>'; } } else { schema.default = '<' + schema.type + (schema.format ? ('-' + schema.format) : '') + '>'; } } } } if (schema.hasOwnProperty('additionalProperties')) { schema.additionalProperties = _.isBoolean(schema.additionalProperties) ? schema.additionalProperties : _resolveSchema(context, schema.additionalProperties, stack, resolveFor, _.cloneDeep(seenRef), utils.addToJsonPath(currentPath, ['additionalProperties'])); schema.type = schema.type || SCHEMA_TYPES.object; } // Resolve refs inside enums to value if (schema.hasOwnProperty('enum')) { _.forEach(schema.enum, (item, index) => { if (item && item.hasOwnProperty('$ref')) { schema.enum[index] = resolveRefFromSchema( context, item.$ref, stack, _.cloneDeep(seenRef) ); } }); } // 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; }, /** * Processes and resolves types from Nested JSON schema structure. * * @param {Object} resolvedSchema - The resolved JSON schema to process for type extraction. * @returns {Object} The processed schema details. */ processSchema = (resolvedSchema) => { if (resolvedSchema.type === 'object' && resolvedSchema.properties) { const schemaDetails = { type: resolvedSchema.type, properties: {}, required: [] }, requiredProperties = new Set(resolvedSchema.required || []); for (let [propName, propValue] of Object.entries(resolvedSchema.properties)) { if (!propValue.type) { continue; } const propertyDetails = { type: propValue.type, deprecated: propValue.deprecated, enum: propValue.enum || undefined, minLength: propValue.minLength, maxLength: propValue.maxLength, minimum: propValue.minimum, maximum: propValue.maximum, pattern: propValue.pattern, example: propValue.example, description: propValue.description, format: propValue.format }; if (requiredProperties.has(propName)) { schemaDetails.required.push(propName); } if (propValue.properties) { let processedProperties = processSchema(propValue); propertyDetails.properties = processedProperties.properties; if (processedProperties.required) { propertyDetails.required = processedProperties.required; } } else if (propValue.type === 'array' && propValue.items) { propertyDetails.items = processSchema(propValue.items); } schemaDetails.properties[propName] = propertyDetails; } if (schemaDetails.required && schemaDetails.required.length === 0) { schemaDetails.required = undefined; } return schemaDetails; } else if (resolvedSchema.type === 'array' && resolvedSchema.items) { const arrayDetails = { type: resolvedSchema.type, items: processSchema(resolvedSchema.items) }; if (resolvedSchema.minItems !== undefined) { arrayDetails.minItems = resolvedSchema.minItems; } if (resolvedSchema.maxItems !== undefined) { arrayDetails.maxItems = resolvedSchema.maxItems; } return arrayDetails; } return { type: resolvedSchema.type }; }, /** * 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 * explode - explode property defined/inferred from schema * startValue - starting value that is prepended to serialised value * propSeparator - Character that separates two properties or values in serialised string of respective param * keyValueSeparator - Character that separates key from values in serialised string of respective param * isExplodable - whether params can be exploded (serialised value can contain key and value) * } */ getParamSerialisationInfo = (context, param, { isResponseSchema = false } = {}) => { let paramName = _.get(param, 'name'), paramSchema, style, // style property defined/inferred from schema explode, // explode property defined/inferred from schema propSeparator, // separates two properties or values keyValueSeparator, // separats key from value startValue = '', // starting value that is unique to each style // following prop represents whether param can be truly exploded, as for some style even when explode is true, // serialisation doesn't separate key-value isExplodable; // for invalid param object return null if (!_.isObject(param)) { return {}; } // Resolve the ref and composite schemas paramSchema = resolveSchema(context, param.schema, { isResponseSchema }); isExplodable = paramSchema.type === 'object'; // decide allowed / default style for respective param location switch (param.in) { case 'path': style = _.includes(['matrix', 'label', 'simple'], param.style) ? param.style : 'simple'; break; case 'query': style = _.includes(['form', 'spaceDelimited', 'pipeDelimited', 'deepObject'], param.style) ? param.style : 'form'; break; case 'header': style = 'simple'; break; default: style = 'simple'; break; } // decide allowed / default explode property for respective param location explode = (_.isBoolean(param.explode) ? param.explode : (_.includes(['form', 'deepObject'], style))); // decide explodable params, starting value and separators between key-value and properties for serialisation switch (style) { case 'matrix': isExplodable = paramSchema.type === 'object' || explode; startValue = ';' + ((paramSchema.type === 'object' && explode) ? '' : (paramName + '=')); propSeparator = explode ? ';' : ','; keyValueSeparator = explode ? '=' : ','; break; case 'label': startValue = '.'; propSeparator = '.'; keyValueSeparator = explode ? '=' : '.'; break; case 'form': // for 'form' when explode is true, query is devided into different key-value pairs propSeparator = keyValueSeparator = ','; break; case 'simple': propSeparator = ','; keyValueSeparator = explode ? '=' : ','; break; case 'spaceDelimited': explode = false; propSeparator = keyValueSeparator = '%20'; break; case 'pipeDelimited': explode = false; propSeparator = keyValueSeparator = '|'; break; case 'deepObject': // for 'deepObject' query is devided into different key-value pairs explode = true; break; default: break; } return { style, explode, startValue, propSeparator, keyValueSeparator, isExplodable }; }, /** * * @param {*} input - input string that needs to be hashed * @returns {*} sha1 hash of the string */ hash = (input) => { return crypto.createHash('sha1').update(input).digest('base64'); }, fakeSchema = (context, schema, shouldGenerateFromExample = true) => { try { let stringifiedSchema = typeof schema === 'object' && (JSON.stringify(schema)), key = hash(stringifiedSchema), restrictArrayItems = typeof stringifiedSchema === 'string' && (stringifiedSchema.length > SCHEMA_SIZE_OPTIMIZATION_THRESHOLD), fakedSchema; // unassign potentially larger string data after calculation as not required stringifiedSchema = null; if (context.schemaFakerCache[key]) { return context.schemaFakerCache[key]; } schemaFaker.option({ useExamplesValue: shouldGenerateFromExample, defaultMinItems: restrictArrayItems ? 1 : 2, defaultMaxItems: restrictArrayItems ? 1 : 2 }); fakedSchema = schemaFaker(schema, null, context.schemaValidationCache || {}); context.schemaFakerCache[key] = fakedSchema; return fakedSchema; } catch (error) { console.warn( 'Error faking a schema. Not faking this schema. Schema:', schema, 'Error', error ); return null; } }, /** * Resolve value of a given parameter * * @param {Object} context - Required context from related SchemaPack function * @param {Object} param - Parameter that is to be resolved from schema * @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, isResponseSchema = false } = {} ) => { if (!param || !param.hasOwnProperty('schema')) { return ''; } const { indentCharacter } = context.computedOptions, resolvedSchema = resolveSchema(context, param.schema, { isResponseSchema }), { parametersResolution } = context.computedOptions, shouldGenerateFromExample = parametersResolution === 'example', hasExample = param.example !== undefined || param.schema.example !== undefined || param.examples !== undefined || param.schema.examples !== undefined; if (shouldGenerateFromExample && hasExample) { /** * Here it could be example or examples (plural) * For examples, we'll pick the first example */ let example; if (param.example !== undefined) { example = param.example; } else if (param.schema.example !== undefined) { example = _.has(param.schema.example, 'value') ? param.schema.example.value : param.schema.example; } else { example = getExampleData(context, param.examples || param.schema.examples); } return example; } schemaFaker.option({ useExamplesValue: true }); if (resolvedSchema.properties) { // If any property exists with format:binary (and type: string) schemaFaker crashes // we just delete based on format=binary for (const prop in resolvedSchema.properties) { if (resolvedSchema.properties.hasOwnProperty(prop)) { if ( resolvedSchema.properties[prop].format === 'byte' || resolvedSchema.properties[prop].format === 'decimal' ) { delete resolvedSchema.properties[prop].format; } } } } try { if (schemaFormat === SCHEMA_FORMATS.XML) { return xmlFaker(null, resolvedSchema, indentCharacter, parametersResolution); } // for JSON, the indentCharacter will be applied in the JSON.stringify step later on return fakeSchema(context, resolvedSchema, shouldGenerateFromExample); } catch (e) { console.warn( 'Error faking a schema. Not faking this schema. Schema:', resolvedSchema, 'Error', e ); return ''; } }, /** * Resolve the url of the Postman request from the operation item * @param {Object} operationPath - Exact path of the operation defined in the schema * @returns {String} Url of the request */ resolveUrlForPostmanRequest = (operationPath) => { return sanitizeUrl(operationPath); }, /** * Recursively extracts key-value pair from deep objects. * * @param {Object} deepObject - Deep object * @param {String} objectKey - key associated with deep object * @returns {Array} array of param key-value pairs */ extractDeepObjectParams = (deepObject, objectKey) => { let extractedParams = []; _.keys(deepObject).forEach((key) => { let value = deepObject[key]; if (value && typeof value === 'object') { extractedParams = _.concat(extractedParams, extractDeepObjectParams(value, objectKey + '[' + key + ']')); } else { extractedParams.push({ key: objectKey + '[' + key + ']', value }); } }); return extractedParams; }, /** * Gets the description of the parameter. * If the parameter is required, it prepends a `(Requried)` before the parameter description * If the parameter type is enum, it appends the possible enum values * @param {object} parameter - input param for which description needs to be returned * @returns {string} description of the parameters */ getParameterDescription = (parameter) => { if (!_.isObject(parameter)) { return ''; } return (parameter.required ? '(Required) ' : '') + (parameter.description || '') + (parameter.enum ? ' (This can only be one of ' + parameter.enum + ')' : ''); }, /** * 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, { isResponseSchema }), { enableOptionalParameters } = context.computedOptions; let serialisedValue = '', description = getParameterDescription(param), paramName = _.get(param, 'name'), disabled = !enableOptionalParameters && _.get(param, 'required') !== true, pmParams = [], isNotSerializable = false; // decide explodable params, starting value and separators between key-value and properties for serialisation // Ref: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#style-examples switch (style) { case 'form': if (explode && _.isObject(paramValue)) { const isArrayValue = _.isArray(paramValue); _.forEach(paramValue, (value, key) => { pmParams.push({ key: isArrayValue ? paramName : key, value: (value === undefined ? '' : _.toString(value)), description, disabled }); }); return pmParams; } break; case 'deepObject': if (_.isObject(paramValue) && !_.isArray(paramValue)) { let extractedParams = extractDeepObjectParams(paramValue, paramName); _.forEach(extractedParams, (extractedParam) => { pmParams.push({ key: extractedParam.key, value: _.toString(extractedParam.value) || '', description, disabled }); }); return pmParams; } else if (_.isArray(paramValue)) { isNotSerializable = true; pmParams.push({ key: paramName, value: '<Error: Not supported in OAS>', disabled }); } break; default: break; } if (isNotSerializable) { return pmParams; } if (_.isObject(paramValue)) { _.forEach(paramValue, (value, key) => { // add property separator for all index/keys except first !_.isEmpty(serialisedValue) && (serialisedValue += propSeparator); // append key for param that can be exploded isExplodable && (serialisedValue += (key + keyValueSeparator)); serialisedValue += (value === undefined ? '' : _.toString(value)); }); } // for non-object and non-empty value append value as is to string else if (!_.isNil(paramValue)) { serialisedValue += paramValue; } // prepend starting value to serialised value (valid for empty value also) serialisedValue = startValue + serialisedValue; pmParams.push({ key: paramName, value: _.toString(serialisedValue), description, disabled }); return pmParams; }, getTypeOfContent = (content) => { if (_.isArray(content)) { return SCHEMA_TYPES.array; } return typeof content; }, /** * Parses media type from given content-type header or media type * from content object into type and subtype * * @param {String} str - string to be parsed * @returns {Object} - Parsed media type into type and subtype */ parseMediaType = (str) => { let simpleMediaTypeRegExp = /^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/, match = simpleMediaTypeRegExp.exec(str), type = '', subtype = ''; if (match) { // as mediatype name are case-insensitive keep it in lower case for uniformity type = _.toLower(match[1]); subtype = _.toLower(match[2]); } return { type, subtype }; }, /** * Get the format of content type header * @param {string} cTypeHeader - the content type header string * @returns {string} type of content type header */ getHeaderFamily = (cTypeHeader) => { let mediaType = parseMediaType(cTypeHeader); if (mediaType.type === 'application' && (mediaType.subtype === 'json' || _.endsWith(mediaType.subtype, '+json'))) { return HEADER_TYPE.JSON; } if ((mediaType.type === 'application' || mediaType.type === 'text') && (mediaType.subtype === 'xml' || _.endsWith(mediaType.subtype, '+xml'))) { return HEADER_TYPE.XML; } return HEADER_TYPE.INVALID; }, /** * Gets XML Example data in correct format based on schema * * @param {Object} context - Global context object * @param {Object} exampleData - Example data to be used * @param {Object} requestBodySchema - Schema of the request body * @returns {String} XML Example data */ getXMLExampleData = (context, exampleData, requestBodySchema) => { const { parametersResolution, indentCharacter } = context.computedOptions; let reqBodySchemaWithExample = requestBodySchema; // Assign example at schema level to be faked by xmlSchemaFaker if (typeof requestBodySchema === 'object') { reqBodySchemaWithExample = Object.assign({}, requestBodySchema, { example: exampleData }); } return xmlFaker(null, reqBodySchemaWithExample, indentCharacter, parametersResolution); }, /** * Generates postman equivalent examples which contains request and response mappings of * 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 * @param {Object} requestBodyExamples - Examples defined in the request body * @param {Object} responseBodySchema - Schema of the response body * @param {Boolean} isXMLExample - Whether the example is XML example * @returns {Array} Examples for corresponding operation */ generateExamples = (context, responseExamples, requestBodyExamples, responseBodySchema, isXMLExample) => { 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) =