Skip to content

Commit

Permalink
feat: validation checking if operationIds are duplicated (#105)
Browse files Browse the repository at this point in the history
* feat: validation checking if operationIds are duplicated

* feat: validation that checks if operationIds are duplicated

* added nice jsdoc

* reuse tilde/untilde instead of custom regex

* better usage of operations

* make sure no fail if operationId is not provided
  • Loading branch information
derberg authored Jul 9, 2020
1 parent fc472cb commit 3d29302
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 44 deletions.
92 changes: 79 additions & 13 deletions lib/customValidators.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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)
});
}

Expand All @@ -70,5 +135,6 @@ function validateChannelParams(parsedJSON, asyncapiYAMLorJSON, initialFormat) {

module.exports = {
validateChannelParams,
validateServerVariables
validateServerVariables,
validateOperationId
};
3 changes: 2 additions & 1 deletion lib/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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];
Expand Down
68 changes: 39 additions & 29 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
};
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'] });
}
});

Expand Down Expand Up @@ -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>} 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)
});
});

Expand Down
103 changes: 102 additions & 1 deletion test/customValidators_test.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
}
}
]);
}
});
});

0 comments on commit 3d29302

Please sign in to comment.