Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: validation checking if operationIds are duplicated #105

Merged
merged 7 commits into from
Jul 9, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}
]);
}
});
});