Skip to content

Commit

Permalink
feat: add validation for duplicate tags (asyncapi#386)
Browse files Browse the repository at this point in the history
Co-authored-by: Maciej Urbańczyk <[email protected]>
  • Loading branch information
BOLT04 and magicmatatjahu authored Nov 15, 2021
1 parent f17424a commit 5b68f49
Show file tree
Hide file tree
Showing 10 changed files with 599 additions and 15 deletions.
253 changes: 240 additions & 13 deletions lib/customValidators.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const ParserError = require('./errors/parser-error');
// eslint-disable-next-line no-unused-vars
const Operation = require('./models/operation');
const {
parseUrlVariables,
getMissingProps,
Expand All @@ -15,8 +17,8 @@ const validationError = 'validation-errors';
* @private
* @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
* @param {String} initialFormat information of the document was originally JSON or YAML
* @returns {Boolean} true in case the document is valid, otherwise throws {@link ParserError}
*/
function validateServerVariables(
parsedJSON,
Expand Down Expand Up @@ -91,7 +93,7 @@ function validateServerVariables(
* @function setNotValidExamples
* @private
* @param {Array<Object>} variables server variables object
* @param {String} srvrName name of the server where variables object is located
* @param {String} srvrName name of the server where variables object is located
* @param {Map} notProvidedExamplesInEnum result map of all wrong examples and what variable they belong to
*/
function setNotValidExamples(variables, srvrName, notProvidedExamplesInEnum) {
Expand All @@ -115,8 +117,8 @@ function setNotValidExamples(variables, srvrName, notProvidedExamplesInEnum) {
* @private
* @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
* @param {String} initialFormat information of the document was originally JSON or YAML
* @returns {Boolean} true in case the document is valid, otherwise throws {@link ParserError}
*/
function validateOperationId(
parsedJSON,
Expand All @@ -129,7 +131,7 @@ function validateOperationId(
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
//is is a 2-dimensional array that holds information with operationId value and its path
const allOperations = [];

const addDuplicateToMap = (op, channelName, opName) => {
Expand Down Expand Up @@ -178,9 +180,9 @@ function validateOperationId(
* @private
* @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
* @param {String} initialFormat information of the document was originally JSON or YAML
* @param {String[]} specialSecTypes list of security types that can have data in array
* @returns {Boolean} true in case the document is valid, otherwise throws ParserError
* @returns {Boolean} true in case the document is valid, otherwise throws {@link ParserError}
*/
function validateServerSecurity(
parsedJSON,
Expand Down Expand Up @@ -305,8 +307,8 @@ function isSrvrSecProperArray(schemaType, specialSecTypes, secObj, secName) {
* @private
* @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
* @param {String} initialFormat information of the document was originally JSON or YAML
* @returns {Boolean} true in case the document is valid, otherwise throws {@link ParserError}
*/
function validateChannels(parsedJSON, asyncapiYAMLorJSON, initialFormat) {
const chnls = parsedJSON.channels;
Expand All @@ -323,7 +325,7 @@ function validateChannels(parsedJSON, asyncapiYAMLorJSON, initialFormat) {
const queryParameters = parseUrlQueryParameters(key);
const unknownServerNames = getUnknownServers(parsedJSON, val);

//channel variable validation: fill return obeject with missing parameters
//channel variable validation: fill return object with missing parameters
if (variables) {
setNotProvidedParams(
variables,
Expand All @@ -339,7 +341,7 @@ function validateChannels(parsedJSON, asyncapiYAMLorJSON, initialFormat) {
invalidChannelName.set(tilde(key), queryParameters);
}

//server validatoin: fill return object with unknown server names
//server validation: fill return object with unknown server names
if (unknownServerNames.length > 0) {
unknownServers.set(tilde(key), unknownServerNames);
}
Expand Down Expand Up @@ -369,7 +371,7 @@ function validateChannels(parsedJSON, asyncapiYAMLorJSON, initialFormat) {
);
const allValidationErrors = parameterValidationErrors.concat(nameValidationErrors).concat(serverValidationErrors);

//channel variable validation: throw exception if channel validation failes
//channel variable validation: throw exception if channel validation fails
if (notProvidedParams.size || invalidChannelName.size || unknownServers.size) {
throw new ParserError({
type: validationError,
Expand All @@ -382,9 +384,234 @@ function validateChannels(parsedJSON, asyncapiYAMLorJSON, initialFormat) {
return true;
}

/**
* Validates if tags specified in the following objects have no duplicates: root, operations, operation traits, channels,
* messages and message traits.
*
* @private
* @param {Object} parsedJSON parsed AsyncAPI document
* @param {String} asyncapiYAMLorJSON AsyncAPI document in string
* @param {String} initialFormat information of the document was originally JSON or YAML
* @returns {Boolean} true in case the document is valid, otherwise throws {@link ParserError}
*/
function validateTags(parsedJSON, asyncapiYAMLorJSON, initialFormat) {
const invalidRoot = validateRootTags(parsedJSON);
const invalidChannels = validateAllChannelsTags(parsedJSON);
const invalidOperationTraits = validateOperationTraitTags(parsedJSON);
const invalidMessages = validateMessageTags(parsedJSON);
const invalidMessageTraits = validateMessageTraitsTags(parsedJSON);
const errorMessage = 'contains duplicate tag names';

let invalidRootValidationErrors = [];
let invalidChannelsValidationErrors = [];
let invalidOperationTraitsValidationErrors = [];
let invalidMessagesValidationErrors = [];
let invalidMessageTraitsValidationErrors = [];

if (invalidRoot.size) {
invalidRootValidationErrors = groupValidationErrors(
null,
errorMessage,
invalidRoot,
asyncapiYAMLorJSON,
initialFormat
);
}

if (invalidChannels.size) {
invalidChannelsValidationErrors = groupValidationErrors(
'channels',
errorMessage,
invalidChannels,
asyncapiYAMLorJSON,
initialFormat
);
}

if (invalidOperationTraits.size) {
invalidOperationTraitsValidationErrors = groupValidationErrors(
'components',
errorMessage,
invalidOperationTraits,
asyncapiYAMLorJSON,
initialFormat
);
}

if (invalidMessages.size) {
invalidMessagesValidationErrors = groupValidationErrors(
'components',
errorMessage,
invalidMessages,
asyncapiYAMLorJSON,
initialFormat
);
}

if (invalidMessageTraits.size) {
invalidMessageTraitsValidationErrors = groupValidationErrors(
'components',
errorMessage,
invalidMessageTraits,
asyncapiYAMLorJSON,
initialFormat
);
}

const allValidationErrors = invalidRootValidationErrors
.concat(invalidChannelsValidationErrors)
.concat(invalidOperationTraitsValidationErrors)
.concat(invalidMessagesValidationErrors)
.concat(invalidMessageTraitsValidationErrors);

if (allValidationErrors.length) {
throw new ParserError({
type: validationError,
title: 'Tags validation failed',
parsedJSON,
validationErrors: allValidationErrors,
});
}

return true;
}

function validateRootTags(parsedJSON) {
const invalidRoot = new Map();
const duplicateNames = parsedJSON.tags && getDuplicateTagNames(parsedJSON.tags);

if (duplicateNames && duplicateNames.length) {
invalidRoot.set('tags', duplicateNames.toString());
}

return invalidRoot;
}

function validateOperationTraitTags(parsedJSON) {
const invalidOperationTraits = new Map();

if (parsedJSON && parsedJSON.components && parsedJSON.components.operationTraits) {
Object.keys(parsedJSON.components.operationTraits).forEach((operationTrait) => {
// eslint-disable-next-line security/detect-object-injection
const duplicateNames = getDuplicateTagNames(parsedJSON.components.operationTraits[operationTrait].tags);

if (duplicateNames && duplicateNames.length) {
const operationTraitsPath = `operationTraits/${operationTrait}/tags`;
invalidOperationTraits.set(
operationTraitsPath,
duplicateNames.toString()
);
}
});
}

return invalidOperationTraits;
}

function validateAllChannelsTags(parsedJSON) {
const chnls = parsedJSON.channels;
if (!chnls) return true;

const chnlsMap = new Map(Object.entries(chnls));
const invalidChannels = new Map();
chnlsMap.forEach((channel, channelName) => validateChannelTags(invalidChannels, channel, channelName));

return invalidChannels;
}

function validateChannelTags(invalidChannels, channel, channelName) {
if (channel.publish) {
validateOperationTags(invalidChannels, channel.publish, `${tilde(channelName)}/publish`);
}

if (channel.subscribe) {
validateOperationTags(invalidChannels, channel.subscribe, `${tilde(channelName)}/subscribe`);
}
}

/**
* Check tags in operation and in message.
*
* @private
* @param {Map} invalidChannels map with invalid channel entries
* @param {Operation} operation operation object
* @param {String} operationPath operation path
*/
function validateOperationTags(invalidChannels, operation, operationPath) {
if (!operation) return;

tryAddInvalidEntries(invalidChannels, `${operationPath}/tags`, operation.tags);

if (operation.message) {
if (operation.message.oneOf) {
operation.message.oneOf.forEach((message, idx) => {
tryAddInvalidEntries(invalidChannels, `${operationPath}/message/oneOf/${idx}/tags`, message.tags);
});
} else {
tryAddInvalidEntries(invalidChannels, `${operationPath}/message/tags`, operation.message.tags);
}
}
}

function tryAddInvalidEntries(invalidChannels, key, tags) {
const duplicateNames = tags && getDuplicateTagNames(tags);
if (duplicateNames && duplicateNames.length) {
invalidChannels.set(key, duplicateNames.toString());
}
}

function validateMessageTraitsTags(parsedJSON) {
const invalidMessageTraits = new Map();

if (parsedJSON && parsedJSON.components && parsedJSON.components.messageTraits) {
Object.keys(parsedJSON.components.messageTraits).forEach((messageTrait) => {
// eslint-disable-next-line security/detect-object-injection
const duplicateNames = getDuplicateTagNames(parsedJSON.components.messageTraits[messageTrait].tags);

if (duplicateNames && duplicateNames.length) {
const messageTraitsPath = `messageTraits/${messageTrait}/tags`;
invalidMessageTraits.set(messageTraitsPath, duplicateNames.toString());
}
});
}

return invalidMessageTraits;
}

function validateMessageTags(parsedJSON) {
const invalidMessages = new Map();

if (parsedJSON && parsedJSON.components && parsedJSON.components.messages) {
Object.keys(parsedJSON.components.messages).forEach((message) => {
// eslint-disable-next-line security/detect-object-injection
const duplicateNames = getDuplicateTagNames(parsedJSON.components.messages[message].tags);

if (duplicateNames && duplicateNames.length) {
const messagePath = `messages/${message}/tags`;
invalidMessages.set(messagePath, duplicateNames.toString());
}
});
}

return invalidMessages;
}

function getDuplicateTagNames(tags) {
if (!tags) return null;

const tagNames = tags.map((item) => item.name);
return tagNames.reduce((acc, item, idx, arr) => {
if (arr.indexOf(item) !== idx && acc.indexOf(item) < 0) {
acc.push(item);
}
return acc;
}, []);
}

module.exports = {
validateServerVariables,
validateOperationId,
validateServerSecurity,
validateChannels,
validateTags,
};
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 { validateChannels, validateServerVariables, validateOperationId, validateServerSecurity } = require('./customValidators.js');
const { validateChannels, validateTags, validateServerVariables, validateOperationId, validateServerSecurity } = require('./customValidators.js');
const { toJS, findRefs, getLocationOf, improveAjvErrors, getDefaultSchemaFormat } = require('./utils');
const AsyncAPIDocument = require('./models/asyncapi');

Expand Down Expand Up @@ -181,6 +181,7 @@ async function customDocumentOperations(parsedJSON, asyncapiYAMLorJSON, initialF

if (!parsedJSON.channels) return;

validateTags(parsedJSON, asyncapiYAMLorJSON, initialFormat);
validateChannels(parsedJSON, asyncapiYAMLorJSON, initialFormat);
validateOperationId(parsedJSON, asyncapiYAMLorJSON, initialFormat, OPERATIONS);

Expand Down
3 changes: 2 additions & 1 deletion lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,10 @@ utils.groupValidationErrors = (root, errorMessage, errorElements, asyncapiYAMLor
errorElements.forEach((val, key) => {
if (typeof val === 'string') val = utils.untilde(val);

const jsonPointer = root ? `/${root}/${key}` : `/${key}`;
errors.push({
title: val ? `${ utils.untilde(key) } ${errorMessage}: ${val}` : `${ utils.untilde(key) } ${errorMessage}`,
location: utils.getLocationOf(`/${root}/${key}`, asyncapiYAMLorJSON, initialFormat)
location: utils.getLocationOf(jsonPointer, asyncapiYAMLorJSON, initialFormat)
});
});

Expand Down
Loading

0 comments on commit 5b68f49

Please sign in to comment.