// @ts-check 'use strict'; const fs = require('fs'); const url = require('url'); const pathlib = require('path'); const maybe = require('call-me-maybe'); const fetch = require('node-fetch-h2'); const yaml = require('yaml'); const jptr = require('reftools/lib/jptr.js'); const resolveInternal = jptr.jptr; const isRef = require('reftools/lib/isref.js').isRef; const clone = require('reftools/lib/clone.js').clone; const cclone = require('reftools/lib/clone.js').circularClone; const recurse = require('reftools/lib/recurse.js').recurse; const resolver = require('oas-resolver'); const sw = require('oas-schema-walker'); const common = require('oas-kit-common'); const statusCodes = require('./lib/statusCodes.js').statusCodes; const ourVersion = require('./package.json').version; // TODO handle specification-extensions with plugins? const targetVersion = '3.0.0'; let componentNames; // initialised in main class S2OError extends Error { constructor(message) { super(message); this.name = 'S2OError'; } } function throwError(message, options) { let err = new S2OError(message); err.options = options; if (options.promise) { options.promise.reject(err); } else { throw err; } } function throwOrWarn(message, container, options) { if (options.warnOnly) { container[options.warnProperty||'x-s2o-warning'] = message; } else { throwError(message, options); } } function fixUpSubSchema(schema,parent,options) { if (schema.nullable) options.patches++; if (schema.discriminator && typeof schema.discriminator === 'string') { schema.discriminator = { propertyName: schema.discriminator }; } if (schema.items && Array.isArray(schema.items)) { if (schema.items.length === 0) { schema.items = {}; } else if (schema.items.length === 1) { schema.items = schema.items[0]; } else schema.items = { anyOf: schema.items }; } if (schema.type && Array.isArray(schema.type)) { if (options.patch) { options.patches++; if (schema.type.length === 0) { delete schema.type; } else { if (!schema.oneOf) schema.oneOf = []; for (let type of schema.type) { let newSchema = {}; if (type === 'null') { schema.nullable = true; } else { newSchema.type = type; for (let prop of common.arrayProperties) { if (typeof schema.prop !== 'undefined') { newSchema[prop] = schema[prop]; delete schema[prop]; } } } if (newSchema.type) { schema.oneOf.push(newSchema); } } delete schema.type; if (schema.oneOf.length === 0) { delete schema.oneOf; // means was just null => nullable } else if (schema.oneOf.length < 2) { schema.type = schema.oneOf[0].type; if (Object.keys(schema.oneOf[0]).length > 1) { throwOrWarn('Lost properties from oneOf',schema,options); } delete schema.oneOf; } } // do not else this if (schema.type && Array.isArray(schema.type) && schema.type.length === 1) { schema.type = schema.type[0]; } } else { throwError('(Patchable) schema type must not be an array', options); } } if (schema.type && schema.type === 'null') { delete schema.type; schema.nullable = true; } if ((schema.type === 'array') && (!schema.items)) { schema.items = {}; } if (schema.type === 'file') { schema.type = 'string'; schema.format = 'binary'; } if (typeof schema.required === 'boolean') { if (schema.required && schema.name) { if (typeof parent.required === 'undefined') { parent.required = []; } if (Array.isArray(parent.required)) parent.required.push(schema.name); } delete schema.required; } // TODO if we have a nested properties (object inside an object) and the // *parent* type is not set, force it to object // TODO if default is set but type is not set, force type to typeof default if (schema.xml && typeof schema.xml.namespace === 'string') { if (!schema.xml.namespace) delete schema.xml.namespace; } if (typeof schema.allowEmptyValue !== 'undefined') { options.patches++; delete schema.allowEmptyValue; } } function fixUpSubSchemaExtensions(schema,parent) { if (schema["x-required"] && Array.isArray(schema["x-required"])) { if (!schema.required) schema.required = []; schema.required = schema.required.concat(schema["x-required"]); delete schema["x-required"]; } if (schema["x-anyOf"]) { schema.anyOf = schema["x-anyOf"]; delete schema["x-anyOf"]; } if (schema["x-oneOf"]) { schema.oneOf = schema["x-oneOf"]; delete schema["x-oneOf"]; } if (schema["x-not"]) { schema.not = schema["x-not"]; delete schema["x-not"]; } if (typeof schema["x-nullable"] === 'boolean') { schema.nullable = schema["x-nullable"]; delete schema["x-nullable"]; } if ((typeof schema["x-discriminator"] === 'object') && (typeof schema["x-discriminator"].propertyName === 'string')) { schema.discriminator = schema["x-discriminator"]; delete schema["x-discriminator"]; for (let entry in schema.discriminator.mapping) { let schemaOrRef = schema.discriminator.mapping[entry]; if (schemaOrRef.startsWith('#/definitions/')) { schema.discriminator.mapping[entry] = schemaOrRef.replace('#/definitions/','#/components/schemas/'); } } } } function fixUpSchema(schema,options) { sw.walkSchema(schema,{},{},function(schema,parent,state){ fixUpSubSchemaExtensions(schema,parent); fixUpSubSchema(schema,parent,options); }); } function getMiroComponentName(ref) { if (ref.indexOf('#')>=0) { ref = ref.split('#')[1].split('/').pop(); } else { ref = ref.split('/').pop().split('.')[0]; } return encodeURIComponent(common.sanitise(ref)); } function fixupRefs(obj, key, state) { let options = state.payload.options; if (isRef(obj,key)) { if (obj[key].startsWith('#/components/')) { // no-op } else if (obj[key] === '#/consumes') { // people are *so* creative delete obj[key]; state.parent[state.pkey] = clone(options.openapi.consumes); } else if (obj[key] === '#/produces') { // and by creative, I mean devious delete obj[key]; state.parent[state.pkey] = clone(options.openapi.produces); } else if (obj[key].startsWith('#/definitions/')) { //only the first part of a schema component name must be sanitised let keys = obj[key].replace('#/definitions/', '').split('/'); const ref = jptr.jpunescape(keys[0]); let newKey = componentNames.schemas[decodeURIComponent(ref)]; // lookup, resolves a $ref if (newKey) { keys[0] = newKey; } else { throwOrWarn('Could not resolve reference '+obj[key],obj,options); } obj[key] = '#/components/schemas/' + keys.join('/'); } else if (obj[key].startsWith('#/parameters/')) { // for extensions like Apigee's x-templates obj[key] = '#/components/parameters/' + common.sanitise(obj[key].replace('#/parameters/', '')); } else if (obj[key].startsWith('#/responses/')) { // for extensions like Apigee's x-templates obj[key] = '#/components/responses/' + common.sanitise(obj[key].replace('#/responses/', '')); } else if (obj[key].startsWith('#')) { // fixes up direct $refs or those created by resolvers let target = clone(jptr.jptr(options.openapi,obj[key])); if (target === false) throwOrWarn('direct $ref not found '+obj[key],obj,options) else if (options.refmap[obj[key]]) { obj[key] = options.refmap[obj[key]]; } else { // we use a heuristic to determine what kind of thing is being referenced let oldRef = obj[key]; oldRef = oldRef.replace('/properties/headers/',''); oldRef = oldRef.replace('/properties/responses/',''); oldRef = oldRef.replace('/properties/parameters/',''); oldRef = oldRef.replace('/properties/schemas/',''); let type = 'schemas'; let schemaIndex = oldRef.lastIndexOf('/schema'); type = (oldRef.indexOf('/headers/')>schemaIndex) ? 'headers' : ((oldRef.indexOf('/responses/')>schemaIndex) ? 'responses' : ((oldRef.indexOf('/example')>schemaIndex) ? 'examples' : ((oldRef.indexOf('/x-')>schemaIndex) ? 'extensions' : ((oldRef.indexOf('/parameters/')>schemaIndex) ? 'parameters' : 'schemas')))); // non-body/form parameters have not moved in the overall structure (like responses) // but extracting the requestBodies can cause the *number* of parameters to change if (type === 'schemas') { fixUpSchema(target,options); } if ((type !== 'responses') && (type !== 'extensions')) { let prefix = type.substr(0,type.length-1); if ((prefix === 'parameter') && target.name && (target.name === common.sanitise(target.name))) { prefix = encodeURIComponent(target.name); } let suffix = 1; if (obj['x-miro']) { prefix = getMiroComponentName(obj['x-miro']); suffix = ''; } while (jptr.jptr(options.openapi,'#/components/'+type+'/'+prefix+suffix)) { suffix = (suffix === '' ? 2 : ++suffix); } let newRef = '#/components/'+type+'/'+prefix+suffix; let refSuffix = ''; if (type === 'examples') { target = { value: target }; refSuffix = '/value'; } jptr.jptr(options.openapi,newRef,target); options.refmap[obj[key]] = newRef+refSuffix; obj[key] = newRef+refSuffix; } } } delete obj['x-miro']; // do this last - rework cases where $ref object has sibling properties if (Object.keys(obj).length > 1) { const tmpRef = obj[key]; const inSchema = state.path.indexOf('/schema') >= 0; // not perfect, but in the absence of a reasonably-sized and complete OAS 2.0 parser... if (options.refSiblings === 'preserve') { // no-op } else if (inSchema && (options.refSiblings === 'allOf')) { delete obj.$ref; state.parent[state.pkey] = { allOf: [ { $ref: tmpRef }, obj ]}; } else { // remove, or not 'preserve' and not in a schema state.parent[state.pkey] = { $ref: tmpRef }; } } } if ((key === 'x-ms-odata') && (typeof obj[key] === 'string') && (obj[key].startsWith('#/'))) { let keys = obj[key].replace('#/definitions/', '').replace('#/components/schemas/','').split('/'); let newKey = componentNames.schemas[decodeURIComponent(keys[0])]; // lookup, resolves a $ref if (newKey) { keys[0] = newKey; } else { throwOrWarn('Could not resolve reference '+obj[key],obj,options); } obj[key] = '#/components/schemas/' + keys.join('/'); } } /* * This has to happen as a separate pass because multiple $refs may point * through elements of the same path */ function dedupeRefs(openapi, options) { for (let ref in options.refmap) { jptr.jptr(openapi,ref,{ $ref: options.refmap[ref] }); } } function processSecurity(securityObject) { for (let s in securityObject) { for (let k in securityObject[s]) { let sname = common.sanitise(k); if (k !== sname) { securityObject[s][sname] = securityObject[s][k]; delete securityObject[s][k]; } } } } function processSecurityScheme(scheme, options) { if (scheme.type === 'basic') { scheme.type = 'http'; scheme.scheme = 'basic'; } if (scheme.type === 'oauth2') { let flow = {}; let flowName = scheme.flow; if (scheme.flow === 'application') flowName = 'clientCredentials'; if (scheme.flow === 'accessCode') flowName = 'authorizationCode'; if (typeof scheme.authorizationUrl !== 'undefined') flow.authorizationUrl = scheme.authorizationUrl.split('?')[0].trim() || '/'; if (typeof scheme.tokenUrl === 'string') flow.tokenUrl = scheme.tokenUrl.split('?')[0].trim() || '/'; flow.scopes = scheme.scopes || {}; scheme.flows = {}; scheme.flows[flowName] = flow; delete scheme.flow; delete scheme.authorizationUrl; delete scheme.tokenUrl; delete scheme.scopes; if (typeof scheme.name !== 'undefined') { if (options.patch) { options.patches++; delete scheme.name; } else { throwError('(Patchable) oauth2 securitySchemes should not have name property', options); } } } } function keepParameters(value) { return (value && !value["x-s2o-delete"]); } function processHeader(header, options) { if (header.$ref) { header.$ref = header.$ref.replace('#/responses/', '#/components/responses/'); } else { if (header.type && !header.schema) { header.schema = {}; } if (header.type) header.schema.type = header.type; if (header.items && header.items.type !== 'array') { if (header.items.collectionFormat !== header.collectionFormat) { throwOrWarn('Nested collectionFormats are not supported', header, options); } delete header.items.collectionFormat; } if (header.type === 'array') { if (header.collectionFormat === 'ssv') { throwOrWarn('collectionFormat:ssv is no longer supported for headers', header, options); // not lossless } else if (header.collectionFormat === 'pipes') { throwOrWarn('collectionFormat:pipes is no longer supported for headers', header, options); // not lossless } else if (header.collectionFormat === 'multi') { header.explode = true; } else if (header.collectionFormat === 'tsv') { throwOrWarn('collectionFormat:tsv is no longer supported', header, options); // not lossless header["x-collectionFormat"] = 'tsv'; } else { // 'csv' header.style = 'simple'; } delete header.collectionFormat; } else if (header.collectionFormat) { if (options.patch) { options.patches++; delete header.collectionFormat; } else { throwError('(Patchable) collectionFormat is only applicable to header.type array', options); } } delete header.type; for (let prop of common.parameterTypeProperties) { if (typeof header[prop] !== 'undefined') { header.schema[prop] = header[prop]; delete header[prop]; } } for (let prop of common.arrayProperties) { if (typeof header[prop] !== 'undefined') { header.schema[prop] = header[prop]; delete header[prop]; } } } } function fixParamRef(param, options) { if (param.$ref.indexOf('#/parameters/') >= 0) { let refComponents = param.$ref.split('#/parameters/'); param.$ref = refComponents[0] + '#/components/parameters/' + common.sanitise(refComponents[1]); } if (param.$ref.indexOf('#/definitions/') >= 0) { throwOrWarn('Definition used as parameter', param, options); } } function attachRequestBody(op,options) { let newOp = {}; for (let key of Object.keys(op)) { newOp[key] = op[key]; if (key === 'parameters') { newOp.requestBody = {}; if (options.rbname) newOp[options.rbname] = ''; } } newOp.requestBody = {}; // just in case there are no parameters return newOp; } /** * @returns op, as it may have changed */ function processParameter(param, op, path, method, index, openapi, options) { let result = {}; let singularRequestBody = true; let originalType; if (op && op.consumes && (typeof op.consumes === 'string')) { if (options.patch) { options.patches++; op.consumes = [op.consumes]; } else { return throwError('(Patchable) operation.consumes must be an array', options); } } if (!Array.isArray(openapi.consumes)) delete openapi.consumes; let consumes = ((op ? op.consumes : null) || (openapi.consumes || [])).filter(common.uniqueOnly); if (param && param.$ref && (typeof param.$ref === 'string')) { // if we still have a ref here, it must be an internal one fixParamRef(param, options); let ptr = decodeURIComponent(param.$ref.replace('#/components/parameters/', '')); let rbody = false; let target = openapi.components.parameters[ptr]; // resolves a $ref, must have been sanitised already if (((!target) || (target["x-s2o-delete"])) && param.$ref.startsWith('#/')) { // if it's gone, chances are it's a requestBody component now unless spec was broken param["x-s2o-delete"] = true; rbody = true; } // shared formData parameters from swagger or path level could be used in any combination. // we dereference all op.requestBody's then hash them and pull out common ones later if (rbody) { let ref = param.$ref; let newParam = resolveInternal(openapi, param.$ref); if (!newParam && ref.startsWith('#/')) { throwOrWarn('Could not resolve reference ' + ref, param, options); } else { if (newParam) param = newParam; // preserve reference } } } if (param && (param.name || param.in)) { // if it's a real parameter OR we've dereferenced it if (typeof param['x-deprecated'] === 'boolean') { param.deprecated = param['x-deprecated']; delete param['x-deprecated']; } if (typeof param['x-example'] !== 'undefined') { param.example = param['x-example']; delete param['x-example']; } if ((param.in !== 'body') && (!param.type)) { if (options.patch) { options.patches++; param.type = 'string'; } else { throwError('(Patchable) parameter.type is mandatory for non-body parameters', options); } } if (param.type && typeof param.type === 'object' && param.type.$ref) { // $ref anywhere sensibility param.type = resolveInternal(openapi, param.type.$ref); } if (param.type === 'file') { param['x-s2o-originalType'] = param.type; originalType = param.type; } if (param.description && typeof param.description === 'object' && param.description.$ref) { // $ref anywhere sensibility param.description = resolveInternal(openapi, param.description.$ref); } if (param.description === null) delete param.description; let oldCollectionFormat = param.collectionFormat; if ((param.type === 'array') && !oldCollectionFormat) { oldCollectionFormat = 'csv'; } if (oldCollectionFormat) { if (param.type !== 'array') { if (options.patch) { options.patches++; delete param.collectionFormat; } else { throwError('(Patchable) collectionFormat is only applicable to param.type array', options); } } if ((oldCollectionFormat === 'csv') && ((param.in === 'query') || (param.in === 'cookie'))) { param.style = 'form'; param.explode = false; } if ((oldCollectionFormat === 'csv') && ((param.in === 'path') || (param.in === 'header'))) { param.style = 'simple'; } if (oldCollectionFormat === 'ssv') { if (param.in === 'query') { param.style = 'spaceDelimited'; } else { throwOrWarn('collectionFormat:ssv is no longer supported except for in:query parameters', param, options); // not lossless } } if (oldCollectionFormat === 'pipes') { if (param.in === 'query') { param.style = 'pipeDelimited'; } else { throwOrWarn('collectionFormat:pipes is no longer supported except for in:query parameters', param, options); // not lossless } } if (oldCollectionFormat === 'multi') { param.explode = true; } if (oldCollectionFormat === 'tsv') { throwOrWarn('collectionFormat:tsv is no longer supported', param, options); // not lossless param["x-collectionFormat"] = 'tsv'; } delete param.collectionFormat; } if (param.type && (param.type !== 'body') && (param.in !== 'formData')) { if (param.items && param.schema) { throwOrWarn('parameter has array,items and schema', param, options); } else { if (param.schema) options.patches++; // already present if ((!param.schema) || (typeof param.schema !== 'object')) param.schema = {}; param.schema.type = param.type; if (param.items) { param.schema.items = param.items; delete param.items; recurse(param.schema.items, null, function (obj, key, state) { if ((key === 'collectionFormat') && (typeof obj[key] === 'string')) { if (oldCollectionFormat && obj[key] !== oldCollectionFormat) { throwOrWarn('Nested collectionFormats are not supported', param, options); } delete obj[key]; // not lossless } // items in 2.0 was a subset of the JSON-Schema items // object, it gets fixed up below }); } for (let prop of common.parameterTypeProperties) { if (typeof param[prop] !== 'undefined') param.schema[prop] = param[prop]; delete param[prop]; } } } if (param.schema) { fixUpSchema(param.schema,options); } if (param["x-ms-skip-url-encoding"]) { if (param.in === 'query') { // might be in:path, not allowed in OAS3 param.allowReserved = true; delete param["x-ms-skip-url-encoding"]; } } } if (param && param.in === 'formData') { // convert to requestBody component singularRequestBody = false; result.content = {}; let contentType = 'application/x-www-form-urlencoded'; if ((consumes.length) && (consumes.indexOf('multipart/form-data') >= 0)) { contentType = 'multipart/form-data'; } result.content[contentType] = {}; if (param.schema) { result.content[contentType].schema = param.schema; if (param.schema.$ref) { result['x-s2o-name'] = decodeURIComponent(param.schema.$ref.replace('#/components/schemas/', '')); } } else { result.content[contentType].schema = {}; result.content[contentType].schema.type = 'object'; result.content[contentType].schema.properties = {}; result.content[contentType].schema.properties[param.name] = {}; let schema = result.content[contentType].schema; let target = result.content[contentType].schema.properties[param.name]; if (param.description) target.description = param.description; if (param.example) target.example = param.example; if (param.type) target.type = param.type; for (let prop of common.parameterTypeProperties) { if (typeof param[prop] !== 'undefined') target[prop] = param[prop]; } if (param.required === true) { if (!schema.required) schema.required = []; schema.required.push(param.name); result.required = true; } if (typeof param.default !== 'undefined') target.default = param.default; if (target.properties) target.properties = param.properties; if (param.allOf) target.allOf = param.allOf; // new are anyOf, oneOf, not if ((param.type === 'array') && (param.items)) { target.items = param.items; if (target.items.collectionFormat) delete target.items.collectionFormat; } if ((originalType === 'file') || (param['x-s2o-originalType'] === 'file')) { target.type = 'string'; target.format = 'binary'; } // Copy any extensions on the form param to the target schema property. copyExtensions(param, target); } } else if (param && (param.type === 'file')) { // convert to requestBody if (param.required) result.required = param.required; result.content = {}; result.content["application/octet-stream"] = {}; result.content["application/octet-stream"].schema = {}; result.content["application/octet-stream"].schema.type = 'string'; result.content["application/octet-stream"].schema.format = 'binary'; copyExtensions(param, result); } if (param && param.in === 'body') { result.content = {}; if (param.name) result['x-s2o-name'] = (op && op.operationId ? common.sanitiseAll(op.operationId) : '') + ('_' + param.name).toCamelCase(); if (param.description) result.description = param.description; if (param.required) result.required = param.required; // Set the "request body name" extension on the operation if requested. if (op && options.rbname && param.name) { op[options.rbname] = param.name; } if (param.schema && param.schema.$ref) { result['x-s2o-name'] = decodeURIComponent(param.schema.$ref.replace('#/components/schemas/', '')); } else if (param.schema && (param.schema.type === 'array') && param.schema.items && param.schema.items.$ref) { result['x-s2o-name'] = decodeURIComponent(param.schema.items.$ref.replace('#/components/schemas/', '')) + 'Array'; } if (!consumes.length) { consumes.push('application/json'); // TODO verify default } for (let mimetype of consumes) { result.content[mimetype] = {}; result.content[mimetype].schema = clone(param.schema || {}); fixUpSchema(result.content[mimetype].schema,options); } // Copy any extensions from the original parameter to the new requestBody copyExtensions(param, result); } if (Object.keys(result).length > 0) { param["x-s2o-delete"] = true; // work out where to attach the requestBody if (op) { if (op.requestBody && singularRequestBody) { op.requestBody["x-s2o-overloaded"] = true; let opId = op.operationId || index; throwOrWarn('Operation ' + opId + ' has multiple requestBodies', op, options); } else { if (!op.requestBody) { op = path[method] = attachRequestBody(op,options); // make sure we have one } if ((op.requestBody.content && op.requestBody.content["multipart/form-data"]) && (op.requestBody.content["multipart/form-data"].schema) && (op.requestBody.content["multipart/form-data"].schema.properties) && (result.content["multipart/form-data"]) && (result.content["multipart/form-data"].schema) && (result.content["multipart/form-data"].schema.properties)) { op.requestBody.content["multipart/form-data"].schema.properties = Object.assign(op.requestBody.content["multipart/form-data"].schema.properties, result.content["multipart/form-data"].schema.properties); op.requestBody.content["multipart/form-data"].schema.required = (op.requestBody.content["multipart/form-data"].schema.required || []).concat(result.content["multipart/form-data"].schema.required||[]); if (!op.requestBody.content["multipart/form-data"].schema.required.length) { delete op.requestBody.content["multipart/form-data"].schema.required; } } else if ((op.requestBody.content && op.requestBody.content["application/x-www-form-urlencoded"] && op.requestBody.content["application/x-www-form-urlencoded"].schema && op.requestBody.content["application/x-www-form-urlencoded"].schema.properties) && result.content["application/x-www-form-urlencoded"] && result.content["application/x-www-form-urlencoded"].schema && result.content["application/x-www-form-urlencoded"].schema.properties) { op.requestBody.content["application/x-www-form-urlencoded"].schema.properties = Object.assign(op.requestBody.content["application/x-www-form-urlencoded"].schema.properties, result.content["application/x-www-form-urlencoded"].schema.properties); op.requestBody.content["application/x-www-form-urlencoded"].schema.required = (op.requestBody.content["application/x-www-form-urlencoded"].schema.required || []).concat(result.content["application/x-www-form-urlencoded"].schema.required||[]); if (!op.requestBody.content["application/x-www-form-urlencoded"].schema.required.length) { delete op.requestBody.content["application/x-www-form-urlencoded"].schema.required; } } else { op.requestBody = Object.assign(op.requestBody, result); if (!op.requestBody['x-s2o-name']) { if (op.requestBody.schema && op.requestBody.schema.$ref) { op.requestBody['x-s2o-name'] = decodeURIComponent(op.requestBody.schema.$ref.replace('#/components/schemas/', '')).split('/').join(''); } else if (op.operationId) { op.requestBody['x-s2o-name'] = common.sanitiseAll(op.operationId); } } } } } } // tidy up if (param && !param['x-s2o-delete']) { delete param.type; for (let prop of common.parameterTypeProperties) { delete param[prop]; } if ((param.in === 'path') && ((typeof param.required === 'undefined') || (param.required !== true))) { if (options.patch) { options.patches++; param.required = true; } else { throwError('(Patchable) path parameters must be required:true ['+param.name+' in '+index+']', options); } } } return op; } function copyExtensions(src, tgt) { for (let prop in src) { if (prop.startsWith('x-') && !prop.startsWith('x-s2o')) { tgt[prop] = src[prop]; } } } function processResponse(response, name, op, openapi, options) { if (!response) return false; if (response.$ref && (typeof response.$ref === 'string')) { if (response.$ref.indexOf('#/definitions/') >= 0) { //response.$ref = '#/components/schemas/'+common.sanitise(response.$ref.replace('#/definitions/','')); throwOrWarn('definition used as response: ' + response.$ref, response, options); } else { if (response.$ref.startsWith('#/responses/')) { response.$ref = '#/components/responses/' + common.sanitise(decodeURIComponent(response.$ref.replace('#/responses/', ''))); } } } else { if ((typeof response.description === 'undefined') || (response.description === null) || ((response.description === '') && options.patch)) { if (options.patch) { if ((typeof response === 'object') && (!Array.isArray(response))) { options.patches++; response.description = (statusCodes[response] || ''); } } else { throwError('(Patchable) response.description is mandatory', options); } } if (typeof response.schema !== 'undefined') { fixUpSchema(response.schema,options); if (response.schema.$ref && (typeof response.schema.$ref === 'string') && response.schema.$ref.startsWith('#/responses/')) { response.schema.$ref = '#/components/responses/' + common.sanitise(decodeURIComponent(response.schema.$ref.replace('#/responses/', ''))); } if (op && op.produces && (typeof op.produces === 'string')) { if (options.patch) { options.patches++; op.produces = [op.produces]; } else { return throwError('(Patchable) operation.produces must be an array', options); } } if (openapi.produces && !Array.isArray(openapi.produces)) delete openapi.produces; let produces = ((op ? op.produces : null) || (openapi.produces || [])).filter(common.uniqueOnly); if (!produces.length) produces.push('*/*'); // TODO verify default response.content = {}; for (let mimetype of produces) { response.content[mimetype] = {}; response.content[mimetype].schema = clone(response.schema); if (response.examples && response.examples[mimetype]) { let example = {}; example.value = response.examples[mimetype]; response.content[mimetype].examples = {}; response.content[mimetype].examples.response = example; delete response.examples[mimetype]; } if (response.content[mimetype].schema.type === 'file') { response.content[mimetype].schema = { type: 'string', format: 'binary' }; } } delete response.schema; } // examples for content-types not listed in produces for (let mimetype in response.examples) { if (!response.content) response.content = {}; if (!response.content[mimetype]) response.content[mimetype] = {}; response.content[mimetype].examples = {}; response.content[mimetype].examples.response = {}; response.content[mimetype].examples.response.value = response.examples[mimetype]; } delete response.examples; if (response.headers) { for (let h in response.headers) { if (h.toLowerCase() === 'status code') { if (options.patch) { options.patches++; delete response.headers[h]; } else { throwError('(Patchable) "Status Code" is not a valid header', options); } } else { processHeader(response.headers[h], options); } } } } } function processPaths(container, containerName, options, requestBodyCache, openapi) { for (let p in container) { let path = container[p]; // path.$ref is external only if (path && (path['x-trace']) && (typeof path['x-trace'] === 'object')) { path.trace = path['x-trace']; delete path['x-trace']; } if (path && (path['x-summary']) && (typeof path['x-summary'] === 'string')) { path.summary = path['x-summary']; delete path['x-summary']; } if (path && (path['x-description']) && (typeof path['x-description'] === 'string')) { path.description = path['x-description']; delete path['x-description']; } if (path && (path['x-servers']) && (Array.isArray(path['x-servers']))) { path.servers = path['x-servers']; delete path['x-servers']; } for (let method in path) { if ((common.httpMethods.indexOf(method) >= 0) || (method === 'x-amazon-apigateway-any-method')) { let op = path[method]; if (op && op.parameters && Array.isArray(op.parameters)) { if (path.parameters) { for (let param of path.parameters) { if (typeof param.$ref === 'string') { fixParamRef(param, options); param = resolveInternal(openapi, param.$ref); } let match = op.parameters.find(function (e, i, a) { return ((e.name === param.name) && (e.in === param.in)); }); if (!match && ((param.in === 'formData') || (param.in === 'body') || (param.type === 'file'))) { op = processParameter(param, op, path, method, p, openapi, options); if (options.rbname && op[options.rbname] === '') { delete op[options.rbname]; } } } } for (let param of op.parameters) { op = processParameter(param, op, path, method, method + ':' + p, openapi, options); } if (options.rbname && op[options.rbname] === '') { delete op[options.rbname]; } if (!options.debug) { if (op.parameters) op.parameters = op.parameters.filter(keepParameters); } } if (op && op.security) processSecurity(op.security); //don't need to remove requestBody for non-supported ops as they "SHALL be ignored" // responses if (typeof op === 'object') { if (!op.responses) { let defaultResp = {}; defaultResp.description = 'Default response'; op.responses = { default: defaultResp }; } for (let r in op.responses) { let response = op.responses[r]; processResponse(response, r, op, openapi, options); } } if (op && (op['x-servers']) && (Array.isArray(op['x-servers']))) { op.servers = op['x-servers']; delete op['x-servers']; } else if (op && op.schemes && op.schemes.length) { for (let scheme of op.schemes) { if ((!openapi.schemes) || (openapi.schemes.indexOf(scheme) < 0)) { if (!op.servers) { op.servers = []; } if (Array.isArray(openapi.servers)) { for (let server of openapi.servers) { let newServer = clone(server); let serverUrl = url.parse(newServer.url); serverUrl.protocol = scheme; newServer.url = serverUrl.format(); op.servers.push(newServer); } } } } } if (options.debug) { op["x-s2o-consumes"] = op.consumes || []; op["x-s2o-produces"] = op.produces || []; } if (op) { delete op.consumes; delete op.produces; delete op.schemes; if (op["x-ms-examples"]) { for (let e in op["x-ms-examples"]) { let example = op["x-ms-examples"][e]; let se = common.sanitiseAll(e); if (example.parameters) { for (let p in example.parameters) { let value = example.parameters[p]; for (let param of (op.parameters||[]).concat(path.parameters||[])) { if (param.$ref) { param = jptr.jptr(openapi,param.$ref); } if ((param.name === p) && (!param.example)) { if (!param.examples) { param.examples = {}; } param.examples[e] = {value: value}; } } } } if (example.responses) { for (let r in example.responses) { if (example.responses[r].headers) { for (let h in example.responses[r].headers) { let value = example.responses[r].headers[h]; for (let rh in op.responses[r].headers) { if (rh === h) { let header = op.responses[r].headers[rh]; header.example = value; } } } } if (example.responses[r].body) { openapi.components.examples[se] = { value: clone(example.responses[r].body) }; if (op.responses[r] && op.responses[r].content) { for (let ct in op.responses[r].content) { let contentType = op.responses[r].content[ct]; if (!contentType.examples) { contentType.examples = {}; } contentType.examples[e] = { $ref: '#/components/examples/'+se }; } } } } } } delete op["x-ms-examples"]; } if (op.parameters && op.parameters.length === 0) delete op.parameters; if (op.requestBody) { let effectiveOperationId = op.operationId ? common.sanitiseAll(op.operationId) : common.sanitiseAll(method + p).toCamelCase(); let rbName = common.sanitise(op.requestBody['x-s2o-name'] || effectiveOperationId || ''); delete op.requestBody['x-s2o-name']; let rbStr = JSON.stringify(op.requestBody); let rbHash = common.hash(rbStr); if (!requestBodyCache[rbHash]) { let entry = {}; entry.name = rbName; entry.body = op.requestBody; entry.refs = []; requestBodyCache[rbHash] = entry; } let ptr = '#/'+containerName+'/'+encodeURIComponent(jptr.jpescape(p))+'/'+method+'/requestBody'; requestBodyCache[rbHash].refs.push(ptr); } } } } if (path && path.parameters) { for (let p2 in path.parameters) { let param = path.parameters[p2]; processParameter(param, null, path, null, p, openapi, options); // index here is the path string } if (!options.debug && Array.isArray(path.parameters)) { path.parameters = path.parameters.filter(keepParameters); } } } } function main(openapi, options) { let requestBodyCache = {}; componentNames = { schemas: {} }; if (openapi.security) processSecurity(openapi.security); for (let s in openapi.components.securitySchemes) { let sname = common.sanitise(s); if (s !== sname) { if (openapi.components.securitySchemes[sname]) { throwError('Duplicate sanitised securityScheme name ' + sname, options); } openapi.components.securitySchemes[sname] = openapi.components.securitySchemes[s]; delete openapi.components.securitySchemes[s]; } processSecurityScheme(openapi.components.securitySchemes[sname], options); } for (let s in openapi.components.schemas) { let sname = common.sanitiseAll(s); let suffix = ''; if (s !== sname) { while (openapi.components.schemas[sname + suffix]) { // @ts-ignore suffix = (suffix ? ++suffix : 2); } openapi.components.schemas[sname + suffix] = openapi.components.schemas[s]; delete openapi.components.schemas[s]; } componentNames.schemas[s] = sname + suffix; fixUpSchema(openapi.components.schemas[sname+suffix],options) } // fix all $refs to their new locations (and potentially new names) options.refmap = {}; recurse(openapi, { payload: { options: options } }, fixupRefs); dedupeRefs(openapi,options); for (let p in openapi.components.parameters) { let sname = common.sanitise(p); if (p !== sname) { if (openapi.components.parameters[sname]) { throwError('Duplicate sanitised parameter name ' + sname, options); } openapi.components.parameters[sname] = openapi.components.parameters[p]; delete openapi.components.parameters[p]; } let param = openapi.components.parameters[sname]; processParameter(param, null, null, null, sname, openapi, options); } for (let r in openapi.components.responses) { let sname = common.sanitise(r); if (r !== sname) { if (openapi.components.responses[sname]) { throwError('Duplicate sanitised response name ' + sname, options); } openapi.components.responses[sname] = openapi.components.responses[r]; delete openapi.components.responses[r]; } let response = openapi.components.responses[sname]; processResponse(response, sname, null, openapi, options); if (response.headers) { for (let h in response.headers) { if (h.toLowerCase() === 'status code') { if (options.patch) { options.patches++; delete response.headers[h]; } else { throwError('(Patchable) "Status Code" is not a valid header', options); } } else { processHeader(response.headers[h], options); } } } } for (let r in openapi.components.requestBodies) { // converted ones let rb = openapi.components.requestBodies[r]; let rbStr = JSON.stringify(rb); let rbHash = common.hash(rbStr); let entry = {}; entry.name = r; entry.body = rb; entry.refs = []; requestBodyCache[rbHash] = entry; } processPaths(openapi.paths, 'paths', options, requestBodyCache, openapi); if (openapi["x-ms-paths"]) { processPaths(openapi["x-ms-paths"], 'x-ms-paths', options, requestBodyCache, openapi); } if (!options.debug) { for (let p in openapi.components.parameters) { let param = openapi.components.parameters[p]; if (param["x-s2o-delete"]) { delete openapi.components.parameters[p]; } } } if (options.debug) { openapi["x-s2o-consumes"] = openapi.consumes || []; openapi["x-s2o-produces"] = openapi.produces || []; } delete openapi.consumes; delete openapi.produces; delete openapi.schemes; let rbNamesGenerated = []; openapi.components.requestBodies = {}; // for now as we've dereffed them if (!options.resolveInternal) { let counter = 1; for (let e in requestBodyCache) { let entry = requestBodyCache[e]; if (entry.refs.length > 1) { // create a shared requestBody let suffix = ''; if (!entry.name) { entry.name = 'requestBody'; // @ts-ignore suffix = counter++; } while (rbNamesGenerated.indexOf(entry.name + suffix) >= 0) { // @ts-ignore - this can happen if descriptions are not exactly the same (e.g. bitbucket) suffix = (suffix ? ++suffix : 2); } entry.name = entry.name + suffix; rbNamesGenerated.push(entry.name); openapi.components.requestBodies[entry.name] = clone(entry.body); for (let r in entry.refs) { let ref = {}; ref.$ref = '#/components/requestBodies/' + entry.name; jptr.jptr(openapi,entry.refs[r],ref); } } } } if (openapi.components.responses && Object.keys(openapi.components.responses).length === 0) { delete openapi.components.responses; } if (openapi.components.parameters && Object.keys(openapi.components.parameters).length === 0) { delete openapi.components.parameters; } if (openapi.components.examples && Object.keys(openapi.components.examples).length === 0) { delete openapi.components.examples; } if (openapi.components.requestBodies && Object.keys(openapi.components.requestBodies).length === 0) { delete openapi.components.requestBodies; } if (openapi.components.securitySchemes && Object.keys(openapi.components.securitySchemes).length === 0) { delete openapi.components.securitySchemes; } if (openapi.components.headers && Object.keys(openapi.components.headers).length === 0) { delete openapi.components.headers; } if (openapi.components.schemas && Object.keys(openapi.components.schemas).length === 0) { delete openapi.components.schemas; } if (openapi.components && Object.keys(openapi.components).length === 0) { delete openapi.components; } return openapi; } function extractServerParameters(server) { if (!server || !server.url || (typeof server.url !== 'string')) return server; server.url = server.url.split('{{').join('{'); server.url = server.url.split('}}').join('}'); server.url.replace(/\{(.+?)\}/g, function (match, group1) { // TODO extend to :parameters (not port)? if (!server.variables) { server.variables = {}; } server.variables[group1] = { default: 'unknown' }; }); return server; } function fixInfo(openapi, options, reject) { if ((typeof openapi.info === 'undefined') || (openapi.info === null)) { if (options.patch) { options.patches++; openapi.info = { version: '', title: '' }; } else { return reject(new S2OError('(Patchable) info object is mandatory')); } } if ((typeof openapi.info !== 'object') || (Array.isArray(openapi.info))) { return reject(new S2OError('info must be an object')); } if ((typeof openapi.info.title === 'undefined') || (openapi.info.title === null)) { if (options.patch) { options.patches++; openapi.info.title = ''; } else { return reject(new S2OError('(Patchable) info.title cannot be null')); } } if ((typeof openapi.info.version === 'undefined') || (openapi.info.version === null)) { if (options.patch) { options.patches++; openapi.info.version = ''; } else { return reject(new S2OError('(Patchable) info.version cannot be null')); } } if (typeof openapi.info.version !== 'string') { if (options.patch) { options.patches++; openapi.info.version = openapi.info.version.toString(); } else { return reject(new S2OError('(Patchable) info.version must be a string')); } } if (typeof openapi.info.logo !== 'undefined') { if (options.patch) { options.patches++; openapi.info['x-logo'] = openapi.info.logo; delete openapi.info.logo; } else return reject(new S2OError('(Patchable) info should not have logo property')); } if (typeof openapi.info.termsOfService !== 'undefined') { if (openapi.info.termsOfService === null) { if (options.patch) { options.patches++; openapi.info.termsOfService = ''; } else { return reject(new S2OError('(Patchable) info.termsOfService cannot be null')); } } try { let u = new URL(openapi.info.termsOfService); } catch (ex) { if (options.patch) { options.patches++; delete openapi.info.termsOfService; } else return reject(new S2OError('(Patchable) info.termsOfService must be a URL')); } } } function fixPaths(openapi, options, reject) { if (typeof openapi.paths === 'undefined') { if (options.patch) { options.patches++; openapi.paths = {}; } else { return reject(new S2OError('(Patchable) paths object is mandatory')); } } } function detectObjectReferences(obj, options) { const seen = new WeakSet(); recurse(obj, {identityDetection:true}, function (obj, key, state) { if ((typeof obj[key] === 'object') && (obj[key] !== null)) { if (seen.has(obj[key])) { if (options.anchors) { obj[key] = clone(obj[key]); } else { throwError('YAML anchor or merge key at '+state.path, options); } } else { seen.add(obj[key]); } } }); } function convertObj(swagger, options, callback) { return maybe(callback, new Promise(function (resolve, reject) { if (!swagger) swagger = {}; options.original = swagger; if (!options.text) options.text = yaml.stringify(swagger); options.externals = []; options.externalRefs = {}; options.rewriteRefs = true; // avoids stack explosions options.preserveMiro = true; options.promise = {}; options.promise.resolve = resolve; options.promise.reject = reject; options.patches = 0; if (!options.cache) options.cache = {}; if (options.source) options.cache[options.source] = options.original; detectObjectReferences(swagger, options); if (swagger.openapi && (typeof swagger.openapi === 'string') && swagger.openapi.startsWith('3.')) { options.openapi = cclone(swagger); fixInfo(options.openapi, options, reject); fixPaths(options.openapi, options, reject); resolver.optionalResolve(options) // is a no-op if options.resolve is not set .then(function(){ if (options.direct) { return resolve(options.openapi); } else { return resolve(options); } }) .catch(function(ex){ console.warn(ex); reject(ex); }); return; // we should have resolved or rejected by now } if ((!swagger.swagger) || (swagger.swagger != "2.0")) { return reject(new S2OError('Unsupported swagger/OpenAPI version: ' + (swagger.openapi ? swagger.openapi : swagger.swagger))); } let openapi = options.openapi = {}; openapi.openapi = (typeof options.targetVersion === 'string' && options.targetVersion.startsWith('3.')) ? options.targetVersion : targetVersion; // semver if (options.origin) { if (!openapi["x-origin"]) { openapi["x-origin"] = []; } let origin = {}; origin.url = options.source||options.origin; origin.format = 'swagger'; origin.version = swagger.swagger; origin.converter = {}; origin.converter.url = 'https://github.com/mermade/oas-kit'; origin.converter.version = ourVersion; openapi["x-origin"].push(origin); } // we want the new and existing properties to appear in a sensible order. Not guaranteed openapi = Object.assign(openapi, cclone(swagger)); delete openapi.swagger; recurse(openapi, {}, function(obj, key, state){ if ((obj[key] === null) && (!key.startsWith('x-')) && key !== 'default' && (state.path.indexOf('/example') < 0)) delete obj[key]; // this saves *so* much grief later }); if (swagger.host) { for (let s of (Array.isArray(swagger.schemes) ? swagger.schemes : [''])) { let server = {}; let basePath = (swagger.basePath || '').replace(/\/$/, '') // Trailing slashes generally shouldn't be included server.url = (s ? s+':' : '') + '//' + swagger.host + basePath; extractServerParameters(server); if (!openapi.servers) openapi.servers = []; openapi.servers.push(server); } } else if (swagger.basePath) { let server = {}; server.url = swagger.basePath; extractServerParameters(server); if (!openapi.servers) openapi.servers = []; openapi.servers.push(server); } delete openapi.host; delete openapi.basePath; if (openapi['x-servers'] && Array.isArray(openapi['x-servers'])) { openapi.servers = openapi['x-servers']; delete openapi['x-servers']; } // TODO APIMatic extensions (x-server-configuration) ? if (swagger['x-ms-parameterized-host']) { let xMsPHost = swagger['x-ms-parameterized-host']; let server = {}; server.url = xMsPHost.hostTemplate + (swagger.basePath ? swagger.basePath : ''); server.variables = {}; const paramNames = server.url.match(/\{\w+\}/g); for (let msp in xMsPHost.parameters) { let param = xMsPHost.parameters[msp]; if (param.$ref) { param = clone(resolveInternal(openapi, param.$ref)); } if (!msp.startsWith('x-')) { delete param.required; // all true delete param.type; // all strings delete param.in; // all 'host' if (typeof param.default === 'undefined') { if (param.enum) { param.default = param.enum[0]; } else { param.default = 'none'; } } if (!param.name) { param.name = paramNames[msp].replace('{','').replace('}',''); } server.variables[param.name] = param; delete param.name; } } if (!openapi.servers) openapi.servers = []; if (xMsPHost.useSchemePrefix === false) { // The server URL already includes a protocol scheme openapi.servers.push(server); } else { // Define this server once for each given protocol scheme swagger.schemes.forEach((scheme) => { openapi.servers.push( Object.assign({}, server, { url: scheme + '://' + server.url }) ) }); } delete openapi['x-ms-parameterized-host']; } fixInfo(openapi, options, reject); fixPaths(openapi, options, reject); if (typeof openapi.consumes === 'string') { openapi.consumes = [openapi.consumes]; } if (typeof openapi.produces === 'string') { openapi.produces = [openapi.produces]; } openapi.components = {}; if (openapi['x-callbacks']) { openapi.components.callbacks = openapi['x-callbacks']; delete openapi['x-callbacks']; } openapi.components.examples = {}; openapi.components.headers = {}; if (openapi['x-links']) { openapi.components.links = openapi['x-links']; delete openapi['x-links']; } openapi.components.parameters = openapi.parameters || {}; openapi.components.responses = openapi.responses || {}; openapi.components.requestBodies = {}; openapi.components.securitySchemes = openapi.securityDefinitions || {}; openapi.components.schemas = openapi.definitions || {}; delete openapi.definitions; delete openapi.responses; delete openapi.parameters; delete openapi.securityDefinitions; resolver.optionalResolve(options) // is a no-op if options.resolve is not set .then(function(){ main(options.openapi, options); if (options.direct) { resolve(options.openapi); } else { resolve(options); } }) .catch(function(ex){ console.warn(ex); reject(ex); }); })); } function convertStr(str, options, callback) { return maybe(callback, new Promise(function (resolve, reject) { let obj = null; let error = null; try { obj = JSON.parse(str); options.text = JSON.stringify(obj,null,2); } catch (ex) { error = ex; try { obj = yaml.parse(str, { schema: 'core', prettyErrors: true }); options.sourceYaml = true; options.text = str; } catch (ex) { error = ex; } } if (obj) { convertObj(obj, options) .then(options => resolve(options)) .catch(ex => reject(ex)); } else { reject(new S2OError(error ? error.message : 'Could not parse string')); } })); } function convertUrl(url, options, callback) { return maybe(callback, new Promise(function (resolve, reject) { options.origin = true; if (!options.source) { options.source = url; } if (options.verbose) { console.warn('GET ' + url); } if (!options.fetch) { options.fetch = fetch; } const fetchOptions = Object.assign({}, options.fetchOptions, {agent:options.agent}); options.fetch(url, fetchOptions).then(function (res) { if (res.status !== 200) throw new S2OError(`Received status code ${res.status}: ${url}`); return res.text(); }).then(function (body) { convertStr(body, options) .then(options => resolve(options)) .catch(ex => reject(ex)); }).catch(function (err) { reject(err); }); })); } function convertFile(filename, options, callback) { return maybe(callback, new Promise(function (resolve, reject) { fs.readFile(filename, options.encoding || 'utf8', function (err, s) { if (err) { reject(err); } else { options.sourceFile = filename; convertStr(s, options) .then(options => resolve(options)) .catch(ex => reject(ex)); } }); })); } function convertStream(readable, options, callback) { return maybe(callback, new Promise(function (resolve, reject) { let data = ''; readable.on('data', function (chunk) { data += chunk; }) .on('end', function () { convertStr(data, options) .then(options => resolve(options)) .catch(ex => reject(ex)); }); })); } module.exports = { S2OError: S2OError, targetVersion: targetVersion, convert: convertObj, convertObj: convertObj, convertUrl: convertUrl, convertStr: convertStr, convertFile: convertFile, convertStream: convertStream };