diff --git a/lib/customValidators.js b/lib/customValidators.js index 14376305e..8333c714b 100644 --- a/lib/customValidators.js +++ b/lib/customValidators.js @@ -1,6 +1,15 @@ const ParserError = require('./errors/parser-error'); -const { parseUrlVariables, getMissingProps, groupValidationErrors } = require('./utils'); +const { parseUrlVariables, getMissingProps, groupValidationErrors, tilde } = require('./utils'); +const validationError = 'validation-errors'; +/** + * Validates if variables provided in the url have corresponding variable object defined + * + * @param {Object} parsedJSON parsed AsyncAPI document + * @param {String} asyncapiYAMLorJSON AsyncAPI document in string + * @param {String} initialFormat information of the document was oryginally JSON or YAML + * @returns {Boolean} true in case the document is valid, otherwise throws ParserError + */ function validateServerVariables(parsedJSON, asyncapiYAMLorJSON, initialFormat) { const srvs = parsedJSON.servers; if (!srvs) return true; @@ -10,30 +19,38 @@ function validateServerVariables(parsedJSON, asyncapiYAMLorJSON, initialFormat) srvsMap.forEach((val, key) => { const variables = parseUrlVariables(val.url); - const notProvidedServerVars = notProvidedVariables.get(key); + const notProvidedServerVars = notProvidedVariables.get(tilde(key)); if (!variables) return; const missingServerVariables = getMissingProps(variables, val.variables); if (!missingServerVariables.length) return; - notProvidedVariables.set(key, + notProvidedVariables.set(tilde(key), notProvidedServerVars ? notProvidedServerVars.concat(missingServerVariables) : missingServerVariables); }); - if (notProvidedVariables.size > 0) { + if (notProvidedVariables.size) { throw new ParserError({ - type: 'validation-errors', + type: validationError, title: 'Not all server variables are described with variable object', parsedJSON, - validationErrors: groupValidationErrors('/servers/', 'server does not have a corresponding variable object for', notProvidedVariables, asyncapiYAMLorJSON, initialFormat) + validationErrors: groupValidationErrors('servers', 'server does not have a corresponding variable object for', notProvidedVariables, asyncapiYAMLorJSON, initialFormat) }); } return true; } - + +/** + * Validates if parameters specified in the channel have corresponding parameters object defined + * + * @param {Object} parsedJSON parsed AsyncAPI document + * @param {String} asyncapiYAMLorJSON AsyncAPI document in string + * @param {String} initialFormat information of the document was oryginally JSON or YAML + * @returns {Boolean} true in case the document is valid, otherwise throws ParserError + */ function validateChannelParams(parsedJSON, asyncapiYAMLorJSON, initialFormat) { const chnls = parsedJSON.channels; if (!chnls) return true; @@ -43,25 +60,73 @@ function validateChannelParams(parsedJSON, asyncapiYAMLorJSON, initialFormat) { chnlsMap.forEach((val, key) => { const variables = parseUrlVariables(key); - const notProvidedChannelParams = notProvidedParams.get(key); + const notProvidedChannelParams = notProvidedParams.get(tilde(key)); if (!variables) return; const missingChannelParams = getMissingProps(variables, val.parameters); if (!missingChannelParams.length) return; - notProvidedParams.set(key, + notProvidedParams.set(tilde(key), notProvidedChannelParams ? notProvidedChannelParams.concat(missingChannelParams) : missingChannelParams); }); - if (notProvidedParams.size > 0) { + if (notProvidedParams.size) { throw new ParserError({ - type: 'validation-errors', + type: validationError, title: 'Not all channel parameters are described with parameter object', parsedJSON, - validationErrors: groupValidationErrors('/channels/', 'channel does not have a corresponding parameter object for', notProvidedParams, asyncapiYAMLorJSON, initialFormat) + validationErrors: groupValidationErrors('channels', 'channel does not have a corresponding parameter object for', notProvidedParams, asyncapiYAMLorJSON, initialFormat) + }); + } + + return true; +} + +/** + * Validates if operationIds are duplicated in the document + * + * @param {Object} parsedJSON parsed AsyncAPI document + * @param {String} asyncapiYAMLorJSON AsyncAPI document in string + * @param {String} initialFormat information of the document was oryginally JSON or YAML + * @returns {Boolean} true in case the document is valid, otherwise throws ParserError + */ +function validateOperationId(parsedJSON, asyncapiYAMLorJSON, initialFormat, operations) { + const chnls = parsedJSON.channels; + if (!chnls) return true; + const chnlsMap = new Map(Object.entries(chnls)); + //it is a map of paths, the one that is a duplicate and the one that is duplicated + const duplicatedOperations = new Map(); + //is is a 2-dimentional array that holds information with operationId value and its path + const allOperations = []; + + const addDuplicateToMap = (op, channelName, opName) => { + const operationId = op.operationId; + if (!operationId) return; + + const operationPath = `${ tilde(channelName) }/${ opName }/operationId`; + const isOperationIdDuplicated = allOperations.filter(v => v[0] === operationId); + if (!isOperationIdDuplicated.length) return allOperations.push([operationId, operationPath]); + + //isOperationIdDuplicated always holds one record and it is an array of paths, the one that is a duplicate and the one that is duplicated + duplicatedOperations.set(operationPath, isOperationIdDuplicated[0][1]); + }; + + chnlsMap.forEach((chnlObj,chnlName) => { + operations.forEach(opName => { + const op = chnlObj[opName]; + if (op) addDuplicateToMap(op, chnlName, opName); + }); + }); + + if (duplicatedOperations.size) { + throw new ParserError({ + type: validationError, + title: 'operationId must be unique across all the operations.', + parsedJSON, + validationErrors: groupValidationErrors('channels', 'is a duplicate of', duplicatedOperations, asyncapiYAMLorJSON, initialFormat) }); } @@ -70,5 +135,6 @@ function validateChannelParams(parsedJSON, asyncapiYAMLorJSON, initialFormat) { module.exports = { validateChannelParams, - validateServerVariables + validateServerVariables, + validateOperationId }; \ No newline at end of file diff --git a/lib/parser.js b/lib/parser.js index a2d771469..21fdbfbac 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -5,7 +5,7 @@ const asyncapi = require('@asyncapi/specs'); const $RefParser = require('@apidevtools/json-schema-ref-parser'); const mergePatch = require('tiny-merge-patch').apply; const ParserError = require('./errors/parser-error'); -const { validateChannelParams, validateServerVariables } = require('./customValidators.js'); +const { validateChannelParams, validateServerVariables, validateOperationId } = require('./customValidators.js'); const { toJS, findRefs, getLocationOf, improveAjvErrors } = require('./utils'); const AsyncAPIDocument = require('./models/asyncapi'); @@ -153,6 +153,7 @@ async function customDocumentOperations(js, asyncapiYAMLorJSON, initialFormat, o if (!js.channels) return; validateChannelParams(js, asyncapiYAMLorJSON, initialFormat); + validateOperationId(js, asyncapiYAMLorJSON, initialFormat, OPERATIONS); for (const channelName in js.channels) { const channel = js.channels[channelName]; diff --git a/lib/utils.js b/lib/utils.js index 9b163918f..f88f3b451 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -7,6 +7,8 @@ const RE2 = require('re2'); const jsonPointerToArray = jsonPointer => (jsonPointer || '/').split('/').splice(1); +const utils = module.exports; + const getAST = (asyncapiYAMLorJSON, initialFormat) => { if (initialFormat === 'yaml') { return yamlAST(asyncapiYAMLorJSON); @@ -15,30 +17,9 @@ const getAST = (asyncapiYAMLorJSON, initialFormat) => { } }; -const tilde = (str) => { - return str.replace(/[~\/]{1}/g, (m) => { - switch (m) { - case '/': return '~1'; - case '~': return '~0'; - } - return m; - }); -}; - -const untilde = (str) => { - if (!str.includes('~')) return str; - return str.replace(/~[01]/g, (m) => { - switch (m) { - case '~1': return '/'; - case '~0': return '~'; - } - return m; - }); -}; - const findNode = (obj, location) => { for (const key of location) { - obj = obj[untilde(key)]; + obj = obj[utils.untilde(key)]; } return obj; }; @@ -47,7 +28,7 @@ const findNodeInAST = (ast, location) => { let obj = ast; for (const key of location) { if (!Array.isArray(obj.children)) return; - const child = obj.children.find(c => c && c.type === 'Property' && c.key && c.key.value === untilde(key)); + const child = obj.children.find(c => c && c.type === 'Property' && c.key && c.key.value === utils.untilde(key)); if (!child) return; obj = child.value; } @@ -95,7 +76,26 @@ const traverse = function (o, fn, scope = []) { } }; -const utils = module.exports; +utils.tilde = (str) => { + return str.replace(/[~\/]{1}/g, (m) => { + switch (m) { + case '/': return '~1'; + case '~': return '~0'; + } + return m; + }); +}; + +utils.untilde = (str) => { + if (!str.includes('~')) return str; + return str.replace(/~[01]/g, (m) => { + switch (m) { + case '~1': return '/'; + case '~0': return '~'; + } + return m; + }); +}; utils.toJS = (asyncapiYAMLorJSON) => { if (!asyncapiYAMLorJSON) { @@ -202,7 +202,7 @@ utils.findRefs = (json, absolutePath, relativePath, initialFormat, asyncapiYAMLo traverse(json, (key, value, scope) => { if (key === '$ref' && possibleRefUrls.includes(value)) { - refs.push({ location: [...scope.map(tilde), '$ref'] }); + refs.push({ location: [...scope.map(utils.tilde), '$ref'] }); } }); @@ -266,15 +266,25 @@ utils.getMissingProps = (arr, obj) => { /** * Returns array of errors messages compatible with validationErrors parameter from ParserError + * + * @param {String} root name of the root element in the AsyncAPI document, for example channels + * @param {String} errorMessage the text of the custom error message that will follow the path that points the error + * @param {Map} errorElements map of error elements cause the validation error might happen in many places in the document. + * The key should have a path information where the error was found, the value holds information about error element + * @param {String} asyncapiYAMLorJSON AsyncAPI document in string + * @param {String} initialFormat information of the document was oryginally JSON or YAML + * @returns {Array} Object has always 2 keys, title and location. Title is a combination of errorElement key + errorMessage + errorElement value. + * Location is the object with information about location of the issue in the file and json Pointer */ utils.groupValidationErrors = (root, errorMessage, errorElements, asyncapiYAMLorJSON, initialFormat) => { const errors = []; - const regex = new RE2(/\//g); - errorElements.forEach((val,key) => { + errorElements.forEach((val, key) => { + if (typeof val === 'string') val = utils.untilde(val); + errors.push({ - title: `${key} ${errorMessage}: ${val}`, - location: utils.getLocationOf(root + key.replace(regex, '~1'), asyncapiYAMLorJSON, initialFormat) + title: `${ utils.untilde(key) } ${errorMessage}: ${val}`, + location: utils.getLocationOf(`/${root}/${key}`, asyncapiYAMLorJSON, initialFormat) }); }); diff --git a/test/customValidators_test.js b/test/customValidators_test.js index 0b19b29b2..a10f74575 100644 --- a/test/customValidators_test.js +++ b/test/customValidators_test.js @@ -1,4 +1,4 @@ -const {validateChannelParams, validateServerVariables} = require('../lib/customValidators.js'); +const {validateChannelParams, validateServerVariables, validateOperationId} = require('../lib/customValidators.js'); const chai = require('chai'); const expect = chai.expect; @@ -301,4 +301,105 @@ describe('validateChannelParams()', function() { expect(() => validateChannelParams(parsedInput, inputString, input)).to.throw('Not all channel parameters are described with parameter object'); }); +}); + +describe('validateOperationId()', function() { + const operations = ['subscribe', 'publish']; + + it('should successfully validate operationId', async function() { + const inputString = `{ + "asyncapi": "2.0.0", + "info": { + "version": "1.0.0" + }, + "channels": { + "test/1": { + "publish": { + "operationId": "test1" + } + }, + "test/2": { + "subscribe": { + "operationId": "test2" + } + } + } + }`; + const parsedInput = JSON.parse(inputString); + + expect(validateOperationId(parsedInput, inputString, input, operations)).to.equal(true); + }); + + it('should successfully validate if channel object not provided', function() { + const inputString = '{}'; + const parsedInput = JSON.parse(inputString); + + expect(validateOperationId(parsedInput, inputString, input, operations)).to.equal(true); + }); + + it('should throw error that operationIds are duplicated and that they duplicate', function() { + const inputString = `{ + "asyncapi": "2.0.0", + "info": { + "version": "1.0.0" + }, + "channels": { + "test/1": { + "publish": { + "operationId": "test" + } + }, + "test/2": { + "subscribe": { + "operationId": "test" + } + }, + "test/3": { + "subscribe": { + "operationId": "test" + } + }, + "test/4": { + "subscribe": { + "operationId": "test4" + } + } + } + }`; + const parsedInput = JSON.parse(inputString); + + try { + validateOperationId(parsedInput, inputString, input, operations); + } catch (e) { + expect(e.type).to.equal('https://github.com/asyncapi/parser-js/validation-errors'); + expect(e.title).to.equal('operationId must be unique across all the operations.'); + expect(e.parsedJSON).to.deep.equal(parsedInput); + expect(e.validationErrors).to.deep.equal([ + { + title: 'test/2/subscribe/operationId is a duplicate of: test/1/publish/operationId', + location: { + jsonPointer: '/channels/test~12/subscribe/operationId', + startLine: 14, + startColumn: 29, + startOffset: 273, + endLine: 14, + endColumn: 35, + endOffset: 279 + } + }, + { + title: 'test/3/subscribe/operationId is a duplicate of: test/1/publish/operationId', + location: { + jsonPointer: '/channels/test~13/subscribe/operationId', + startLine: 19, + startColumn: 29, + startOffset: 375, + endLine: 19, + endColumn: 35, + endOffset: 381 + } + } + ]); + } + }); }); \ No newline at end of file