// @ts-check 'use strict'; const fs = require('fs'); const url = require('url'); const yaml = require('yaml'); const should = require('should/as-function'); const maybe = require('call-me-maybe'); const common = require('oas-kit-common'); const jptr = require('reftools/lib/jptr.js'); const resolveInternal = jptr.jptr; const clone = require('reftools/lib/clone.js').clone; const recurse = require('reftools/lib/recurse.js').recurse; const isRef = require('reftools/lib/isref.js').isRef; const sw = require('oas-schema-walker'); const linter = require('oas-linter'); const resolver = require('oas-resolver'); const dummySchema = { anyOf: {} }; const emptySchema = {}; let refSeen = {}; function contextAppend(options, s) { options.context.push((options.context[options.context.length - 1] + '/' + s).split('//').join('/')); } function validateUrl(s, contextServers, context, options) { let effSource = options.source || 'http://localhost/'; if (effSource.indexOf('://') < 0) { effSource = url.pathToFileURL(effSource); } should(s).be.a.String(); should(s).not.be.Null(); if (!options.laxurls) should(s).not.be.exactly('', 'Invalid empty URL ' + context); let base = effSource; let variables = {}; if (contextServers && contextServers.length) { let servers = contextServers[0]; if (servers && servers.length) { base = servers[0].url; variables = servers[0].variables; for (let v in variables) { base = base.replace('{'+v+'}',variables[v].default); } if (base.indexOf('://') < 0) { base = new URL(base, effSource).toString(); } } } if (s.indexOf('://') > 0) { // FIXME HACK base = undefined; } let u = new URL(s, base); return true; // if we haven't thrown } function validateComponentName(name) { return /^[a-zA-Z0-9\.\-_]+$/.test(name); } function validateHeaderName(name) { return /^[A-Za-z0-9!#\-\$%&'\*\+\\\.\^_`\|~]+$/.test(name); } function checkSubSchema(schema, parent, state) { let prop = state.property; if (prop) contextAppend(state.options, prop); if (state.options.lint) state.options.linter('schema',schema,prop,state.options); should(schema).be.an.Object(); should(schema).not.be.an.Array(); if (typeof schema.$ref !== 'undefined') { should(schema.$ref).be.a.String(); if (state.options.lint) state.options.linter('reference',schema,'$ref',state.options); if (prop) state.options.context.pop(); return; // all other properties SHALL be ignored (3.0) } for (let k in schema) { if (!k.startsWith('x-')) { should(['type','items','format','properties','required','minimum','maximum', 'exclusiveMinimum','exclusiveMaximum','enum','default','description','title', 'readOnly','writeOnly','anyOf','allOf','oneOf','not','discriminator','maxItems', 'minItems','additionalItems','additionalProperties','example','maxLength', 'minLength','pattern','uniqueItems','xml','externalDocs','nullable','deprecated', 'minProperties','maxProperties','multipleOf'].indexOf(k)). be.greaterThan(-1,'Schema object cannot have additionalProperty: '+k); } } if (typeof schema.multipleOf !== 'undefined') { should(schema.multipleOf).be.type('number','multipleOf must be a number'); should(schema.multipleOf).be.greaterThan(0); } if (typeof schema.maximum !== 'undefined') { should(schema.maximum).be.type('number','maximum must be a number'); } if (typeof schema.exclusiveMaximum !== 'undefined') { should(schema.exclusiveMaximum).be.type('boolean','exclusiveMaximum must be a boolean'); } if (typeof schema.minimum !== 'undefined') { should(schema.minimum).be.type('number','minimum must be a number'); } if (typeof schema.exclusiveMinimum !== 'undefined') { should(schema.exclusiveMinimum).be.type('boolean','exclusiveMinimum must be a boolean'); } if (typeof schema.maxLength !== 'undefined') { should(schema.maxLength).be.type('number','maxLength must be a number'); should(schema.maxLength).be.greaterThan(-1); } if (typeof schema.minLength !== 'undefined') { should(schema.minLength).be.type('number','minLength must be a number'); should(schema.minLength).be.greaterThan(-1); } if (schema.pattern) { try { let regex = new RegExp(schema.pattern); } catch (ex) { should.fail(false,true,'pattern does not conform to ECMA-262'); } } if (typeof schema.items !== 'undefined') { should(schema.items).be.an.Object(); should(schema.items).not.be.an.Array(); } if (schema.additionalItems) { if (typeof schema.additionalItems === 'boolean') { } else if (typeof schema.additionalItems === 'object') { should(schema.additionalItems).not.be.an.Array(); } else should.fail(false,true,'additionalItems must be a boolean or schema'); } if (schema.additionalProperties) { if (typeof schema.additionalProperties === 'boolean') { } else if (typeof schema.additionalProperties === 'object') { should(schema.additionalProperties).not.be.an.Array(); } else should.fail(false,true,'additionalProperties must be a boolean or schema'); } if (typeof schema.maxItems !== 'undefined') { should(schema.maxItems).be.type('number','maxItems must be a number'); should(schema.maxItems).be.greaterThan(-1); } if (typeof schema.minItems !== 'undefined') { should(schema.minItems).be.type('number','minItems must be a number'); should(schema.minItems).be.greaterThan(-1); } if (typeof schema.uniqueItems !== 'undefined') { should(schema.uniqueItems).be.type('boolean','uniqueItems must be a boolean'); } if (typeof schema.maxProperties !== 'undefined') { should(schema.maxProperties).be.type('number','maxProperties must be a number'); should(schema.maxProperties).be.greaterThan(-1); } if (typeof schema.minProperties !== 'undefined') { should(schema.minProperties).be.type('number','minProperties must be a number'); should(schema.minProperties).be.greaterThan(-1); } if (typeof schema.required !== 'undefined') { should(schema.required).be.an.Array(); should(schema.required).not.be.empty(); should(common.hasDuplicates(schema.required)).be.exactly(false,'required items must be unique: '+common.firstDupe(schema.required)); // nb: required array can include (for example) specific properties which match patternProperties and which aren't listed in properties } if (schema.properties) { should(schema.properties).be.an.Object(); should(schema.properties).not.be.an.Array(); } should(schema).not.have.property('patternProperties'); /*if (schema.patternProperties) { should(schema.patternProperties).be.an.Object(); for (let prop in schema.patternProperties) { try { let regex = new RegExp(prop); } catch (ex) { should.fail(false,true,'patternProperty '+prop+' does not conform to ECMA-262'); } } }*/ if (typeof schema.enum !== 'undefined') { should(schema.enum).be.an.Array(); should(schema.enum).not.be.empty(); // items only SHOULD be unique } if (typeof schema.type !== 'undefined') { should(schema.type).be.a.String(); // not an array should(schema.type).equalOneOf('integer','number','string','boolean','object','array'); // not null if (schema.type === 'array') { should(schema).have.property('items'); } } if (typeof schema.allOf !== 'undefined') { should(schema.allOf).be.an.Array(); should(schema.allOf).not.be.empty(); } if (typeof schema.anyOf !== 'undefined') { should(schema.anyOf).be.an.Array(); should(schema.anyOf).not.be.empty(); } if (typeof schema.oneOf !== 'undefined') { should(schema.oneOf).be.an.Array(); should(schema.oneOf).not.be.empty(); } if (schema.not) { should(schema.not).be.an.Object(); should(schema.not).not.be.an.Array(); } if (typeof schema.title !== 'undefined') { should(schema.title).be.a.String(); } if (typeof schema.description !== 'undefined') { should(schema.description).be.a.String(); } if (!state.options.laxDefaults && typeof schema.default !== 'undefined') { should(schema).have.property('type'); let realType = typeof schema.default; let schemaType = schema.type; if (Array.isArray(schema.default)) realType = 'array'; if (schemaType === 'integer') schemaType = 'number'; if ((schema.default === null) && (schema.nullable)) { // nop } else { should(schemaType).equal(realType); } } if (typeof schema.format !== 'undefined') { should(schema.format).be.a.String(); if (schema.type && ['date-time','email','hostname','ipv4','ipv6','uri','uriref', 'byte','binary','date','password'].indexOf(schema.format) >= 0) { should(schema.type).equal('string',`Invalid type ${schema.type} for format ${schema.format}`); } if (schema.type && ['int32','int64'].indexOf(schema.format) >= 0) { if (schema.type !== 'string' && schema.type !== 'number') { // common case - googleapis should(schema.type).equal('integer',`Invalid type ${schema.type} for format ${schema.format}`); } } if (schema.type && ['float','double'].indexOf(schema.format) >= 0) { if (schema.type !== 'string') { // occasionally seen should(schema.type).equal('number',`Invalid type ${schema.type} for format ${schema.format}`); } } } if (typeof schema.nullable !== 'undefined') { should(schema.nullable).be.type('boolean','nullable must be a boolean'); } if (typeof schema.readOnly !== 'undefined') { should(schema.readOnly).be.type('boolean','readOnly must be a boolean'); if (schema.readOnly && schema.writeOnly) { should(schema).not.have.property('writeOnly'); } } if (typeof schema.writeOnly !== 'undefined') { should(schema.writeOnly).be.type('boolean','writeOnly must be a boolean'); if (schema.readOnly && schema.writeOnly) { should(schema).not.have.property('readOnly'); } } if (typeof schema.deprecated !== 'undefined') { should(schema.deprecated).be.type('boolean','deprecated must be a boolean'); } if (typeof schema.discriminator !== 'undefined') { should(schema.discriminator).be.an.Object(); should(schema.discriminator).not.be.an.Array(); should(schema.discriminator).have.property('propertyName'); //"To avoid redundancy, the discriminator MAY be added to a parent schema definition..." //if ((Object.keys(parent).length>0) && !(parent.oneOf || parent.anyOf || parent.allOf)) { // should.fail(false,true,'discriminator requires oneOf, anyOf or allOf in parent schema'); //} } if (typeof schema.xml !== 'undefined') { should(schema.xml).be.an.Object(); should(schema.xml).not.be.an.Array(); } // example can be any type if (typeof schema.externalDocs !== 'undefined') { should(schema.externalDocs).be.an.Object(); should(schema.externalDocs).not.be.an.Array(); should(schema.externalDocs).have.key('url'); should.doesNotThrow(function() { validateUrl(schema.externalDocs.url, [state.openapi.servers], 'externalDocs', state.options)}, 'Invalid externalDocs.url'); if (state.options.lint) state.options.linter('externalDocs',schema.externalDocs,'externalDocs',state.options); } if (prop) state.options.context.pop(); } function checkSchema(schema,parent,prop,openapi,options) { if ((typeof schema.$ref === 'string') && (!refSeen[schema.$ref])) { // check if the $reffed thing is actually a schema object (and not a response etc) // all other properties SHALL be ignored (3.0) const refSchema = resolveInternal(openapi,schema.$ref); should(refSchema).not.be.exactly(false, 'Cannot resolve reference: ' + schema.$ref); refSeen[schema.$ref] = checkSchema(refSchema,parent,prop,openapi,options); } else { let state = sw.getDefaultState(); state.openapi = openapi; state.options = options; state.property = prop; sw.walkSchema(schema,parent,state,checkSubSchema); } return true; } function checkExample(ex, contextServers, openapi, options) { should(ex).be.an.Object(); should(ex).not.be.an.Array(); if (typeof ex.summary !== 'undefined') { should(ex.summary).have.type('string'); } if (typeof ex.description !== 'undefined') { should(ex.description).have.type('string'); } if (typeof ex.value !== 'undefined') { should(ex).not.have.property('externalValue'); } if (typeof ex.externalValue !== 'undefined') { should(ex).not.have.property('value'); should.doesNotThrow(function () { validateUrl(ex.externalValue, contextServers, 'examples..externalValue', options) },'Invalid examples..externalValue'); } for (let k in ex) { if (!k.startsWith('x-')) { should(['summary','description','value','externalValue'].indexOf(k)).be.greaterThan(-1,'Example object cannot have additionalProperty: '+k); } } if (options.lint) options.linter('example',ex,'example',options); } function checkContent(content, contextServers, openapi, options) { contextAppend(options, 'content'); should(content).be.an.Object(); should(content).not.be.an.Array(); for (let ct in content) { contextAppend(options, jptr.jpescape(ct)); // validate ct against https://tools.ietf.org/html/rfc6838#section-4.2 if (options.mediatype) { should(/[a-zA-Z0-9!#$%^&\*_\-\+{}\|'.`~]+\/[a-zA-Z0-9!#$%^&\*_\-\+{}\|'.`~]+/.test(ct)).be.exactly(true,'media-type should match RFC6838 format'); // this is a SHOULD not MUST } let contentType = content[ct]; should(contentType).be.an.Object(); should(contentType).not.be.an.Array(); if (typeof contentType.schema !== 'undefined') { checkSchema(contentType.schema,emptySchema,'schema',openapi,options); } if (typeof contentType.example !== 'undefined') { should(contentType).not.have.property('examples'); } if (typeof contentType.examples !== 'undefined') { contextAppend(options, 'examples'); should(contentType).not.have.property('example'); should(contentType.examples).be.an.Object(); should(contentType.examples).not.be.an.Array(); for (let e in contentType.examples) { let ex = contentType.examples[e]; if (typeof ex.$ref === 'undefined') { checkExample(ex, contextServers, openapi, options); } else { if (options.lint) options.linter('reference',ex,'$ref',options); } } options.context.pop(); } for (let k in contentType) { if (!k.startsWith('x-')) { should(['schema','example','examples','encoding'].indexOf(k)).be.greaterThan(-1,'mediaType object cannot have additionalProperty: '+k); } } if (options.lint) options.linter('content',content,ct,options); options.context.pop(); } options.context.pop(); } function checkServer(server, options) { should(server).have.property('url'); if (typeof server.description !== 'undefined') { should(server.description).be.a.String(); } let u = server.url; let srvVars = 0; server.url.replace(/\{(.+?)\}/g, function (match, group1) { srvVars++; should(server).have.key('variables'); should(server.variables).have.key(group1); }); if (typeof server.variables !== 'undefined') { contextAppend(options, 'variables'); should(server.variables).be.an.Object(); should(server.variables).not.be.an.Array(); for (let v in server.variables) { contextAppend(options, v); should(server.variables[v]).be.an.Object(); should(server.variables[v]).have.key('default'); should(server.variables[v].default).be.a.String(); u = u.replace('{'+v+'}',server.variables[v].default); if (typeof server.variables[v].enum !== 'undefined') { contextAppend(options, 'enum'); should(server.variables[v].enum).be.an.Array(); should(server.variables[v].enum.length).not.be.exactly(0, 'Server variables enum should not be empty'); for (let e in server.variables[v].enum) { contextAppend(options, e); should(server.variables[v].enum[e]).be.type('string'); options.context.pop(); } options.context.pop(); } if (options.lint) options.linter('serverVariable',server.variables[v],v,options); options.context.pop(); } should(Object.keys(server.variables).length).be.exactly(srvVars,'Missing template variable in server url'); options.context.pop(); } should.doesNotThrow(function () { validateUrl(u, [], 'server.url', options) },'Invalid server.url'); for (let k in server) { if (!k.startsWith('x-')) { should(['url','description','variables'].indexOf(k)).be.greaterThan(-1,'server object cannot have additionalProperty: '+k); } } if (options.lint) options.linter('server',server,'server',options); } function checkServers(servers, options) { should(servers).be.an.Array(); //should(common.distinctArray(servers)).be.exactly(true,'servers array must be distinct'); // TODO move to linter servers.forEach(function(server, s) { contextAppend(options, s); checkServer(server, options); options.context.pop(); }); } function checkLink(link, openapi, options) { if (typeof link.$ref !== 'undefined') { let ref = link.$ref; should(ref).be.type('string'); if (refSeen[ref]) return true; // bail out refSeen[ref] = true; if (options.lint) options.linter('reference',link,'$ref',options); link = resolveInternal(openapi, ref); should(link).not.be.exactly(false, 'Cannot resolve reference: ' + ref); } should(link).be.type('object'); if (typeof link.operationRef === 'undefined') { should(link).have.property('operationId'); } else { should(link.operationRef).be.type('string'); should(link).not.have.property('operationId'); } if (typeof link.operationId === 'undefined') { should(link).have.property('operationRef'); } else { should(link.operationId).be.type('string'); should(link).not.have.property('operationRef'); // validate operationId exists (external refs?) } if (typeof link.parameters != 'undefined') { should(link.parameters).be.type('object'); should(link.parameters).not.be.an.Array(); } if (typeof link.description !== 'undefined') { should(link.description).have.type('string'); } if (typeof link.server !== 'undefined') { checkServer(link.server, options); } for (let k in link) { if (!k.startsWith('x-')) { should(['operationRef','operationId','parameters','requestBody','description','server'].indexOf(k)).be.greaterThan(-1,'link object cannot have additionalProperty: '+k); } } if (options.lint) options.linter('link',link,'link',options); } function checkHeader(header, contextServers, openapi, options) { if (typeof header.$ref !== 'undefined') { let ref = header.$ref; should(ref).be.type('string'); if (refSeen[ref]) return true; // bail out refSeen[ref] = true; if (options.lint) options.linter('reference',header,'$ref',options); header = resolveInternal(openapi, ref); should(header).not.be.exactly(false, 'Cannot resolve reference: ' + ref); } should(header).not.have.property('name'); should(header).not.have.property('in'); should(header).not.have.property('type'); for (let prop of common.parameterTypeProperties) { should(header).not.have.property(prop); } if (typeof header.schema !== 'undefined') { should(header).not.have.property('content'); if (typeof header.style !== 'undefined') { should(header.style).be.type('string'); should(header.style).be.exactly('simple'); } if (typeof header.explode !== 'undefined') { should(header.explode).be.type('boolean'); } if (typeof header.allowReserved !== 'undefined') { should(header.allowReserved).be.type('boolean'); } checkSchema(header.schema, emptySchema, 'schema', openapi, options); } if (header.content) { should(header).not.have.property('schema'); should(header).not.have.property('style'); should(header).not.have.property('explode'); should(header).not.have.property('allowReserved'); should(header).not.have.property('example'); should(header).not.have.property('examples'); checkContent(header.content, contextServers, openapi, options); } if (!header.schema && !header.content) { should(header).have.property('schema', 'Header should have schema or content'); } for (let k in header) { if (!k.startsWith('x-')) { should(['description','required','deprecated','allowEmptyValue','style','explode','allowReserved','schema','example','examples','content'].indexOf(k)).be.greaterThan(-1,'header object cannot have additionalProperty: '+k); } } if (options.lint) options.linter('header',header,'header',options); } function checkResponse(response, key, contextServers, openapi, options) { should(response).not.be.null(); if (typeof response.$ref !== 'undefined') { let ref = response.$ref; should(ref).be.type('string'); if (refSeen[ref]) return true; // bail out refSeen[ref] = true; if (options.lint) options.linter('reference',response,'$ref',options); response = resolveInternal(openapi, ref); should(response).not.be.exactly(false, 'Cannot resolve reference: ' + ref); } should(response).have.property('description'); should(response.description).have.type('string', 'response description should be of type string'); should(response).not.have.property('examples'); should(response).not.have.property('schema'); if (response.headers) { contextAppend(options, 'headers'); for (let h in response.headers) { contextAppend(options, h); should(validateHeaderName(h)).be.equal(true, 'Header doesn\'t match RFC7230 pattern'); checkHeader(response.headers[h], contextServers, openapi, options); options.context.pop(); } options.context.pop(); } if (response.content) { checkContent(response.content, contextServers, openapi, options); } if (typeof response.links !== 'undefined') { contextAppend(options, 'links'); for (let l in response.links) { contextAppend(options, l); checkLink(response.links[l], openapi, options); options.context.pop(); } options.context.pop(); } for (let k in response) { if (!k.startsWith('x-')) { should(['description','headers','content','links'].indexOf(k)).be.greaterThan(-1,'response object cannot have additionalProperty: '+k); } } if (options.lint) options.linter('response',response,key,options); } function checkParam(param, index, path, contextServers, openapi, options) { const ref = param.$ref; contextAppend(options, index); if (typeof param.$ref !== 'undefined') { should(ref).be.type('string'); if (options.lint) options.linter('reference',param,'$ref',options); param = resolveInternal(openapi, ref); if (refSeen[ref] && (param.in !== 'path')) { options.context.pop(); return param; // bail out } should(param).not.be.exactly(false, 'Cannot resolve reference: ' + ref); } should(param).have.property('name'); should(param.name).have.type('string'); should(param).have.property('in'); should(param.in).have.type('string'); should(param.in).equalOneOf('query', 'header', 'path', 'cookie'); if (param.in === 'path') { should(param).have.property('required'); should(param.required).be.exactly(true, 'Path parameters must have an explicit required:true'); if (path) { // if we're not looking at a param from #/components (checked when used) should(path.indexOf('{'+param.name+'}')).be.greaterThan(-1,'path parameters must appear in the path'); } } if (typeof param.required !== 'undefined') should(param.required).have.type('boolean'); should(param).not.have.property('items'); should(param).not.have.property('collectionFormat'); should(param).not.have.property('type'); for (let prop of common.parameterTypeProperties) { should(param).not.have.property(prop); } should(param.in).not.be.exactly('body', 'Parameter type body is no-longer valid'); should(param.in).not.be.exactly('formData', 'Parameter type formData is no-longer valid'); if (param.description) { should(param.description).have.type('string'); } if (typeof param.deprecated !== 'undefined') { should(param.deprecated).be.a.Boolean(); } if (typeof param.schema !== 'undefined') { should(param).not.have.property('content'); if (typeof param.style !== 'undefined') { should(param.style).be.type('string'); if (param.in === 'path') { should(param.style).not.be.exactly('form'); should(param.style).not.be.exactly('spaceDelimited'); should(param.style).not.be.exactly('pipeDelimited'); should(param.style).not.be.exactly('deepObject'); } if (param.in === 'query') { should(param.style).not.be.exactly('matrix'); should(param.style).not.be.exactly('label'); should(param.style).not.be.exactly('simple'); } if (param.in === 'header') { should(param.style).be.exactly('simple'); } if (param.in === 'cookie') { should(param.style).be.exactly('form'); } } if (typeof param.explode !== 'undefined') { should(param.explode).be.type('boolean'); } if (typeof param.allowReserved !== 'undefined') { should(param.allowReserved).be.type('boolean'); } if (typeof param.example !== 'undefined') { should(param).not.have.key('examples'); } if (typeof param.examples !== 'undefined') { contextAppend(options, 'examples'); should(param).not.have.key('example'); should(param.examples).be.an.Object(); should(param.examples).not.be.an.Array(); for (let e in param.examples) { contextAppend(options, e); let example = param.examples[e]; checkExample(example, contextServers, openapi, options); options.context.pop(); } options.context.pop(); } checkSchema(param.schema, emptySchema, 'schema', openapi, options); } if (param.content) { should(param).not.have.property('schema'); should(param).not.have.property('style'); should(param).not.have.property('explode'); should(param).not.have.property('allowReserved'); should(param).not.have.property('example'); should(param).not.have.property('examples'); should(Object.keys(param.content).length).be.exactly(1, 'Parameter content must have only one entry'); checkContent(param.content, contextServers, openapi, options); } if (!param.schema && !param.content) { should(param).have.property('schema', 'Parameter should have schema or content'); } for (let k in param) { if (!k.startsWith('x-')) { should(['name','in','description','required','deprecated','allowEmptyValue','style','explode','allowReserved','schema','example','examples','content'].indexOf(k)).be.greaterThan(-1,'parameter object cannot have additionalProperty: '+k); } } if (options.lint) options.linter('parameter',param,index,options); options.context.pop(); if (ref) { refSeen[ref] = param; } return param; } function checkPathItem(pathItem, path, openapi, options) { should(pathItem).be.an.Object(); should(pathItem).not.be.an.Array(); let contextServers = []; contextServers.push(openapi.servers); if (pathItem.servers) contextServers.push(pathItem.servers); let pathParameters = {}; if (typeof pathItem.parameters !== 'undefined') should(pathItem.parameters).be.an.Array(); contextAppend(options, 'parameters'); for (let p in pathItem.parameters) { let param = checkParam(pathItem.parameters[p], p, path, contextServers, openapi, options); if (pathParameters[param.in+':'+param.name]) { should.fail(false,true,'Duplicate path-level parameter '+param.name); } else { pathParameters[param.in+':'+param.name] = param; } } options.context.pop(); for (let o in pathItem) { contextAppend(options, o); let op = pathItem[o]; if (o === '$ref') { should(op).be.ok(); should(op).have.type('string'); should(op.startsWith('#/') && (!op.startsWith('#/paths/')) && (op.indexOf('x-')<0)).equal(false,'PathItem $refs must be external or to specification extension ('+op+')'); if (options.lint) options.linter('reference',pathItem,'$ref',options); if (!options.metadata.count.operation) { options.metadata.count.operation = 0; } options.metadata.count.operation++; } else if (o === 'parameters') { // checked above } else if (o === 'servers') { contextAppend(options, 'servers'); checkServers(op, options); // won't be here in converted definitions options.context.pop(); } else if (o === 'summary') { should(pathItem.summary).have.type('string'); } else if (o === 'description') { should(pathItem.description).have.type('string'); } else if (common.httpMethods.indexOf(o) >= 0) { should(op).be.an.Object(); should(op).not.be.an.Array(); should(op).not.have.property('consumes'); should(op).not.have.property('produces'); should(op).not.have.property('schemes'); should(op).have.property('responses'); contextAppend(options, 'responses'); should(op.responses).not.be.undefined(); should(op.responses).be.an.Object(); should(op.responses).not.be.an.Array(); should(op.responses).not.be.empty(); options.context.pop(); if (typeof op.summary !== 'undefined') should(op.summary).have.type('string'); if (typeof op.description !== 'undefined') should(op.description).be.a.String(); if (typeof op.operationId !== 'undefined') { should(op.operationId).be.a.String(); should(options.operationIds.indexOf(op.operationId)).be.exactly(-1,'operationIds must be unique ['+op.operationId+']'); options.operationIds.push(op.operationId); } if (typeof op.servers !== 'undefined') { contextAppend(options, 'servers'); checkServers(op.servers, options); // won't be here in converted definitions options.context.pop(); contextServers.push(op.servers); } if (typeof op.tags !== 'undefined') { contextAppend(options, 'tags'); should(op.tags).be.an.Array(); for (let tag of op.tags) { should(tag).be.a.String(); } options.context.pop(); } if (typeof op.requestBody !== 'undefined') { contextAppend(options, 'requestBody'); should(op.requestBody).not.be.null(); should(op.requestBody).be.an.Object(); should(op.requestBody).not.be.an.Array(); if (typeof op.requestBody.description !== 'undefined') should(op.requestBody.description).have.type('string'); if (typeof op.requestBody.required !== 'undefined') should(op.requestBody.required).have.type('boolean'); if (typeof op.requestBody.content !== 'undefined') { checkContent(op.requestBody.content, contextServers, openapi, options); } options.context.pop(); } contextAppend(options, 'responses'); if (options.lint) options.linter('responses',op.responses,'responses',options); for (let r in op.responses) { if (!r.startsWith('x-')) { contextAppend(options, r); if ((r !== 'default') && (!r.match('^[1-5](?:\\d{2}|XX)$'))) { should.fail(false,true,'Responses object should not have additional property '+r); } let response = op.responses[r]; checkResponse(response, r, contextServers, openapi, options); options.context.pop(); } } options.context.pop(); let localPathParameters = clone(pathParameters); let opParameters = {}; if (typeof op.parameters !== 'undefined') { should(op.parameters).be.an.Array(); contextAppend(options, 'parameters'); for (let p in op.parameters) { let param = checkParam(op.parameters[p], p, path, contextServers, openapi, options); if (opParameters[param.in+':'+param.name]) { should.fail(false,true,'Duplicate operation-level parameter '+param.name); } else { opParameters[param.in+':'+param.name] = param; delete localPathParameters[param.in+':'+param.name]; } } options.context.pop(); } let contextParameters = Object.assign({},localPathParameters,opParameters); let prepath = path.split('?')[0]; prepath.replace(/\{(.+?)\}/g, function (match, group1) { if (!contextParameters['path:'+group1]) { if (!group1.startsWith('$')) { // callbacks should.fail(false,true,'Templated parameter '+group1+' not found'); } } }); if (typeof op.deprecated !== 'undefined') { should(op.deprecated).be.a.Boolean(); } if (typeof op.externalDocs !== 'undefined') { contextAppend(options, 'externalDocs'); should(op.externalDocs).be.an.Object(); should(op.externalDocs).not.be.an.Array(); should(op.externalDocs).have.key('url'); if (typeof op.externalDocs.description !== 'undefined') { should(op.externalDocs.description).be.a.String(); } should.doesNotThrow(function () { validateUrl(op.externalDocs.url, contextServers, 'externalDocs', options) },'Invalid externalDocs.url'); if (options.lint) options.linter('externalDocs',op.externalDocs,'externalDocs',options); options.context.pop(); } if (op.callbacks) { contextAppend(options, 'callbacks'); for (let c in op.callbacks) { let callback = op.callbacks[c]; if (callback && typeof callback.$ref !== 'undefined') { if (options.lint) options.linter('reference',callback,'$ref',options); } else { contextAppend(options, c); for (let p in callback) { let cbPi = callback[p]; options.isCallback = true; checkPathItem(cbPi, p, openapi, options); options.isCallback = false; } options.context.pop(); } } options.context.pop(); } if (op.security) { checkSecurity(op.security,openapi,options); } if (options.lint) options.linter('operation',op,o,options); } else if (!o.startsWith('x-')) { should.fail(false,true,'PathItem should not have additional property '+o); } options.context.pop(); } if (options.lint) options.linter('pathItem',pathItem,path,options); return true; } function checkSecurity(security,openapi,options) { contextAppend(options, 'security'); should(security).be.an.Array(); //should(common.distinctArray(security)).be.exactly(true,'security array should be distinct'); // TODO move to linter for (let sr of security) { should(sr).be.an.Object(); should(sr).not.be.an.Array(); for (let i in sr) { should(sr[i]).be.an.Array(); let sec = jptr.jptr(openapi,'#/components/securitySchemes/'+i); should(sec).not.be.exactly(false,'Could not dereference securityScheme '+i); if (sec.type !== 'openIdConnect') { if (sec.type !== 'oauth2') { should(sr[i]).be.empty(); } else if (sr[i].length) { if (!options.allScopes[i]) { options.allScopes[i] = {}; if (sec.flows) { if (sec.flows.password) Object.assign(options.allScopes[i],sec.flows.password.scopes); if (sec.flows.implicit) Object.assign(options.allScopes[i],sec.flows.implicit.scopes); if (sec.flows.authorizationCode) Object.assign(options.allScopes[i],sec.flows.authorizationCode.scopes); if (sec.flows.clientCredentials) Object.assign(options.allScopes[i],sec.flows.clientCredentials.scopes); } } for (let scope of sr[i]) { should(options.allScopes[i]).have.property(scope); } } } } } if (options.lint) options.linter('security',security,'security',options); options.context.pop(); } function validateInner(openapi, options, callback) { return maybe(callback, new Promise(function (resolve, reject) { try { setupOptions(options,openapi); let contextServers = []; should(openapi).be.an.Object(); should(openapi).not.have.key('swagger'); should(openapi).have.key('openapi'); should(openapi.openapi).have.type('string'); should.ok(openapi.openapi.startsWith('3.0.'), 'Must be an OpenAPI 3.0.x document'); should(openapi).not.have.key('host'); should(openapi).not.have.key('basePath'); should(openapi).not.have.key('schemes'); should(openapi).have.key('paths'); should(openapi.paths).be.an.Object(); should(openapi).not.have.key('definitions'); should(openapi).not.have.key('parameters'); should(openapi).not.have.key('responses'); should(openapi).not.have.key('securityDefinitions'); should(openapi).not.have.key('produces'); should(openapi).not.have.key('consumes'); for (let k in openapi) { if (!k.startsWith('x-')) { should(['openapi','info','servers','security','externalDocs','tags','paths','components'].indexOf(k)).be.greaterThan(-1,'OpenAPI object cannot have additionalProperty: '+k); } } if (typeof openapi.servers !== 'undefined') { should(openapi.servers).be.an.Object(); contextAppend(options, 'servers'); checkServers(openapi.servers, options); options.context.pop(); contextServers.push(openapi.servers); } should(openapi).have.key('info'); should(openapi.info).be.an.Object(); should(openapi.info).not.be.an.Array(); contextAppend(options, 'info'); should(openapi.info).have.key('title'); should(openapi.info.title).be.type('string', 'title should be of type string'); should(openapi.info).have.key('version'); should(openapi.info.version).be.type('string', 'version should be of type string'); if (typeof openapi.info.license !== 'undefined') { should(openapi.info.license).be.an.Object(); should(openapi.info.license).not.be.an.Array(); contextAppend(options, 'license'); should(openapi.info.license).have.key('name'); should(openapi.info.license.name).have.type('string'); if (typeof openapi.info.license.url !== 'undefined') { should.doesNotThrow(function () { validateUrl(openapi.info.license.url, contextServers, 'license.url', options) },'Invalid license.url'); } if (options.lint) options.linter('license',openapi.info.license,'license',options); options.context.pop(); } if (typeof openapi.info.termsOfService !== 'undefined') { should.doesNotThrow(function () { validateUrl(openapi.info.termsOfService, contextServers, 'termsOfService', options) },'Invalid termsOfService.url'); } if (typeof openapi.info.contact !== 'undefined') { contextAppend(options, 'contact'); should(openapi.info.contact).be.type('object'); should(openapi.info.contact).not.be.an.Array(); should(openapi.info.contact).not.be.Null(); if (typeof openapi.info.contact.name !== 'undefined') { should(openapi.info.contact.name).be.a.String(); } if (typeof openapi.info.contact.url !== 'undefined') { should.doesNotThrow(function () { validateUrl(openapi.info.contact.url, contextServers, 'url', options) },'Invalid contact.url'); } if (typeof openapi.info.contact.email !== 'undefined') { should(openapi.info.contact.email).be.a.String(); should(openapi.info.contact.email.indexOf('@')).be.greaterThan(-1,'Contact email must be a valid email address'); should(openapi.info.contact.email.indexOf('.')).be.greaterThan(-1,'Contact email must be a valid email address'); } if (options.lint) options.linter('contact',openapi.info.contact,'contact',options); for (let k in openapi.info.contact) { if (!k.startsWith('x-')) { should(['name','url','email'].indexOf(k)).be.greaterThan(-1,'info object cannot have additionalProperty: '+k); } } options.context.pop(); } if (typeof openapi.info.description !== 'undefined') { should(openapi.info.description).be.a.String(); } if (options.lint) options.linter('info',openapi.info,'info',options); options.context.pop(); if (typeof openapi.externalDocs !== 'undefined') { should(openapi.externalDocs).be.an.Object(); contextAppend(options, 'externalDocs'); should(openapi.externalDocs).have.key('url'); if (typeof openapi.externalDocs.description !== 'undefined') { should(openapi.externalDocs.description).be.a.String(); } should.doesNotThrow(function () { validateUrl(openapi.externalDocs.url, contextServers, 'externalDocs', options) },'Invalid externalDocs.url'); if (options.lint) options.linter('externalDocs',openapi.externalDocs,'externalDocs',options); options.context.pop(); } if (typeof openapi.paths !== 'undefined'){ if (options.lint) options.linter('paths',openapi.paths,'paths',options); } if (typeof openapi.tags !== 'undefined') { should(openapi.tags).be.an.Array(); contextAppend(options, 'tags'); let tagsSeen = new Map(); for (let tag of openapi.tags) { should(tag).have.property('name'); contextAppend(options, tag.name); should(tag.name).be.a.String(); should(tagsSeen.has(tag.name)).be.exactly(false,'Tag names must be unique ['+tag.name+']'); tagsSeen.set(tag.name,true); if (typeof tag.externalDocs !== 'undefined') { contextAppend(options, 'externalDocs'); should(tag.externalDocs).be.an.Object(); should(tag.externalDocs).not.be.an.Array(); if (typeof tag.externalDocs.description !== 'undefined') { should(tag.externalDocs.description).be.a.String(); } should(tag.externalDocs).have.key('url'); should.doesNotThrow(function () { validateUrl(tag.externalDocs.url, contextServers, 'tag.externalDocs', options) },'Invalid externalDocs.url'); if (options.lint) options.linter('externalDocs',tag.externalDocs,'externalDocs',options); options.context.pop(); } if (typeof tag.description !== 'undefined') { should(tag.description).be.a.String(); } if (options.lint) options.linter('tag',tag,tag.name,options); // should be index options.context.pop(); } options.context.pop(); } if (typeof openapi.security !== 'undefined') { checkSecurity(openapi.security,openapi,options); } if (typeof openapi.components !== 'undefined') { options.context.push('#/components'); should(openapi.components).be.an.Object(); should(openapi.components).not.be.an.Array(); if (options.lint) options.linter('components',openapi.components,'components',options); options.context.pop(); } if (openapi.components && (typeof openapi.components.securitySchemes !== 'undefined')) { options.context.push('#/components/securitySchemes'); should(openapi.components.securitySchemes).be.an.Object(); should(openapi.components.securitySchemes).not.be.an.Array(); for (let s in openapi.components.securitySchemes) { options.context.push('#/components/securitySchemes/' + s); should(validateComponentName(s)).be.equal(true, 'component name invalid'); let scheme = openapi.components.securitySchemes[s]; should(scheme).have.property('type'); should(scheme.type).have.type('string'); should(scheme.type).not.be.exactly('basic', 'Security scheme basic should be http with scheme basic'); should(scheme.type).equalOneOf('apiKey', 'http', 'oauth2', 'openIdConnect'); if (scheme.type === 'http') { should(scheme).have.property('scheme'); should(scheme.scheme).have.type('string'); if (scheme.scheme != 'bearer') { should(scheme).not.have.property('bearerFormat'); } } else { should(scheme).not.have.property('scheme'); should(scheme).not.have.property('bearerFormat'); } if (scheme.type === 'apiKey') { should(scheme).have.property('name'); should(scheme.name).have.type('string'); should(scheme).have.property('in'); should(scheme.in).have.type('string'); should(scheme.in).equalOneOf('query', 'header', 'cookie'); } else { should(scheme).not.have.property('name'); should(scheme).not.have.property('in'); } if (scheme.type === 'oauth2') { should(scheme).not.have.property('flow'); should(scheme).have.property('flows'); contextAppend(options,'flows'); for (let f in scheme.flows) { contextAppend(options,f); let flow = scheme.flows[f]; should(['implicit','password','authorizationCode','clientCredentials'].indexOf(f)).be.greaterThan(-1,'Unknown flow type: '+f); if ((f === 'implicit') || (f === 'authorizationCode')) { should(flow).have.property('authorizationUrl'); should.doesNotThrow(function () { validateUrl(flow.authorizationUrl, contextServers, 'authorizationUrl', options) },'Invalid authorizationUrl'); } else { should(flow).not.have.property('authorizationUrl'); } if ((f === 'password') || (f === 'clientCredentials') || (f === 'authorizationCode')) { should(flow).have.property('tokenUrl'); should.doesNotThrow(function () { validateUrl(flow.tokenUrl, contextServers, 'tokenUrl', options) },'Invalid tokenUrl'); } else { should(flow).not.have.property('tokenUrl'); } if (typeof flow.refreshUrl !== 'undefined') { should.doesNotThrow(function () { validateUrl(flow.refreshUrl, contextServers, 'refreshUrl', options) },'Invalid refreshUrl'); } should(flow).have.property('scopes'); should(flow.scopes).be.an.Object(); should(flow.scopes).not.be.an.Array(); options.context.pop(); } options.context.pop(); } else { should(scheme).not.have.property('flows'); } if (scheme.type === 'openIdConnect') { should(scheme).have.property('openIdConnectUrl'); should.doesNotThrow(function () { validateUrl(scheme.openIdConnectUrl, contextServers, 'openIdConnectUrl', options) },'Invalid openIdConnectUrl'); } else { should(scheme).not.have.property('openIdConnectUrl'); } if (options.lint) options.linter('securityScheme',scheme,s,options); options.context.pop(); } options.context.pop(); } let seen = new WeakSet(); recurse(openapi, {identityDetection:true}, function (obj, key, state) { if (!options.anchors && (typeof obj[key] === 'object') && (obj[key] !== null)) { if (seen.has(obj[key])) { options.context.push(state.path); should.fail(true,false,'Definition contains YAML anchor or merge key'); options.context.pop(); // won't actually get here } else { seen.add(obj[key]); } } if (isRef(obj,key) && (!refSeen[obj[key]])) { options.context.push(state.path); should(obj[key]).not.startWith('#/definitions/'); let refUrl = url.parse(obj[key]); if (!refUrl.protocol && !refUrl.path) { should(obj[key]+'/%24ref').not.be.equal(state.path,'Circular reference'); if (obj[key].indexOf('/examples/')<0) { should(jptr.jptr(openapi,obj[key])).not.be.exactly(false, 'Cannot resolve reference: ' + obj[key]); } } options.context.pop(); } }); seen = null; let paths = {}; for (let p in openapi.paths) { options.context.push('#/paths/' + jptr.jpescape(p)); if (!p.startsWith('x-')) { should(p).startWith('/'); if (!options.laxurls) should(p).not.containEql('?'); if (!options.laxurls) should(p).not.containEql('#'); let pCount = 0; let template = p.replace(/\{(.+?)\}/g, function (match, group1) { return '{'+(pCount++)+'}'; }); if (paths[template] && !openapi["x-hasEquivalentPaths"]) { should.fail(false,true,'Identical path templates detected'); } paths[template] = {}; let templateCheck = p.replace(/\{(.+?)\}/g, function (match, group1) { return ''; }); if ((templateCheck.indexOf('{')>=0) || (templateCheck.indexOf('}')>=0)) { should.fail(false,true,'Mismatched {} in path template'); } checkPathItem(openapi.paths[p], p, openapi, options); } options.context.pop(); } if (openapi["x-ms-paths"]) { for (let p in openapi["x-ms-paths"]) { options.context.push('#/x-ms-paths/' + jptr.jpescape(p)); should(p).startWith('/'); //if (!options.laxurls) should(p).not.containEql('?'); if (!options.laxurls) should(p).not.containEql('#'); checkPathItem(openapi["x-ms-paths"][p], p, openapi, options); options.context.pop(); } } if (openapi.components && (typeof openapi.components.parameters !== 'undefined')) { options.context.push('#/components/parameters/'); should(openapi.components.parameters).be.an.Object(); should(openapi.components.parameters).not.be.an.Array(); for (let p in openapi.components.parameters) { checkParam(openapi.components.parameters[p], p, '', contextServers, openapi, options); contextAppend(options, p); should(validateComponentName(p)).be.equal(true, 'component name invalid'); options.context.pop(); } options.context.pop(); } if (openapi.components && (typeof openapi.components.schemas !== 'undefined')) { options.context.push('#/components/schemas'); should(openapi.components.schemas).be.an.Object(); should(openapi.components.schemas).not.be.an.Array(); for (let s in openapi.components.schemas) { options.context.push('#/components/schemas/' + s); should(validateComponentName(s)).be.equal(true, 'component name invalid'); checkSchema(openapi.components.schemas[s], dummySchema, '', openapi, options); options.context.pop(); } options.context.pop(); } if (openapi.components && (typeof openapi.components.responses !== 'undefined')) { options.context.push('#/components/responses'); should(openapi.components.responses).be.an.Object(); should(openapi.components.responses).not.be.an.Array(); for (let r in openapi.components.responses) { options.context.push('#/components/responses/' + r); should(validateComponentName(r)).be.equal(true, 'component name invalid'); checkResponse(openapi.components.responses[r], r, contextServers, openapi, options); options.context.pop(); } options.context.pop(); } if (openapi.components && (typeof openapi.components.headers !== 'undefined')) { options.context.push('#/components/headers'); should(openapi.components.headers).be.an.Object(); should(openapi.components.headers).not.be.an.Array(); for (let h in openapi.components.headers) { options.context.push('#/components/headers/' + h); should(validateComponentName(h)).be.equal(true, 'component name invalid'); checkHeader(openapi.components.headers[h], contextServers, openapi, options); options.context.pop(); } options.context.pop(); } if (openapi.components && (typeof openapi.components.requestBodies !== 'undefined')) { options.context.push('#/components/requestBodies'); should(openapi.components.requestBodies).be.an.Object(); should(openapi.components.requestBodies).not.be.an.Array(); for (let r in openapi.components.requestBodies) { options.context.push('#/components/requestBodies/' + r); should(validateComponentName(r)).be.equal(true, 'component name invalid'); if (r.startsWith('requestBody')) { options.warnings.push('Anonymous requestBody: ' + r); } let rb = openapi.components.requestBodies[r]; should(rb).have.property('content'); if (typeof rb.description !== 'undefined') should(rb.description).have.type('string'); if (typeof rb.required !== 'undefined') should(rb.required).have.type('boolean'); checkContent(rb.content, openapi.servers, openapi, options); options.context.pop(); } options.context.pop(); } if (openapi.components && (typeof openapi.components.examples !== 'undefined')) { options.context.push('#/components/examples'); should(openapi.components.examples).be.an.Object(); should(openapi.components.examples).not.be.an.Array(); for (let e in openapi.components.examples) { options.context.push('#/components/examples/' + e); should(validateComponentName(e)).be.equal(true, 'component name invalid'); let ex = openapi.components.examples[e]; if (typeof ex.$ref === 'undefined') { checkExample(ex, openapi.servers, openapi, options); } else { if (options.lint) options.linter('reference',ex,'$ref',options); } } options.context.pop(); } if (openapi.components && (typeof openapi.components.callbacks !== 'undefined')) { options.context.push('#/components/callbacks'); should(openapi.components.callbacks).be.an.Object(); should(openapi.components.callbacks).not.be.an.Array(); for (let c in openapi.components.callbacks) { options.context.push('#/components/callbacks/' + c); should(validateComponentName(c)).be.equal(true, 'component name invalid'); let cb = openapi.components.callbacks[c]; if (typeof cb.$ref === 'undefined') { for (let exp in cb) { let cbPi = cb[exp]; options.isCallback = true; checkPathItem(cbPi, exp, openapi, options); options.isCallback = false; } } else { if (options.lint) options.linter('reference',cb,'$ref',options); } options.context.pop(); } options.context.pop(); } if (openapi.components && (typeof openapi.components.links !== 'undefined')) { options.context.push('#/components/links'); should(openapi.components.links).be.type('object'); should(openapi.components.links).not.be.an.Array(); for (let l in openapi.components.links) { options.context.push('#/components/links/' + l); should(validateComponentName(l)).be.equal(true, 'component name invalid'); let link = openapi.components.links[l]; if (typeof link.$ref === 'undefined') { checkLink(link, openapi, options); } else { if (options.lint) options.linter('reference',link,'$ref',options); } options.context.pop(); } options.context.pop(); } options.valid = !options.expectFailure; if (options.lint) options.linter('openapi',openapi,'',options); options.warnings = options.warnings.filter(common.uniqueOnly); if (options.verbose > 1) { for (let w=0;w options.lintLimit ? `, showing first ${options.lintLimit}` : ''}`); resolve(options.valid); } catch (ex) { ex.options = options; reject(ex); throw ex; } })); } function setupOptions(options,openapi) { refSeen = {}; options.valid = false; options.context = [ '#/' ]; options.warnings = []; if (!options.lintLimit) options.lintLimit = 5; if (!options.lintSkip) options.lintSkip = []; options.operationIds = []; options.allScopes = {}; options.openapi = openapi; if (options.lint && !options.linter) { options.linter = linter.lint; linter.loadDefaultRules(); options.linterResults = linter.getResults; } if (!options.cache) options.cache = {}; options.metadata = { lines: -1, count: {} }; if ((options.text) && (typeof options.text === 'string')) options.metadata.lines = options.text.split('\n').length; } function validate(openapi, options, callback) { return maybe(callback, new Promise(function (resolve, reject) { setupOptions(options,openapi); let actions = []; resolver.optionalResolve(options) .then(function(){ options.context = []; validateInner(openapi, options) .then(function(){ return resolve(options); }) .catch(function(err){ err.options = options; return reject(err); }); }) .catch(function (err) { return reject(err); }); })); } function microValidate(openapi, options) { return (openapi && openapi.openapi && openapi.info && (typeof openapi.info.version !== 'undefined') && (typeof openapi.info.title !== 'undefined') && openapi.paths); } function optionallyValidate(openapi, options) { const target = options.externalRef||openapi; if (microValidate(target, options)) { validateInner(target, options, function(err, options) { if (err) throw err; }); } return openapi } module.exports = { validateInner: validateInner, validate: validate, microValidate: microValidate, optionallyValidate: optionallyValidate }