diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index 768b9c4ae..c88945b09 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -353,6 +353,28 @@ tags: **Recommended:** No +### asyncapi-tags-uniqueness + +Tags must not have duplicate names (identifiers). + +**Recommended:** Yes + +**Bad Example** + +```yaml +tags: + - name: "Badger" + - name: "Badger" +``` + +**Good Example** + +```yaml +tags: + - name: "Aardvark" + - name: "Badger" +``` + ### asyncapi-tags AsyncAPI object should have non-empty `tags` array. diff --git a/docs/reference/openapi-rules.md b/docs/reference/openapi-rules.md index 2e4e17f3b..3abcbcb47 100644 --- a/docs/reference/openapi-rules.md +++ b/docs/reference/openapi-rules.md @@ -250,6 +250,28 @@ tags: - name: "Badger" ``` +### openapi-tags-uniqueness + +OpenAPI object must not have duplicated tag names (identifiers). + +**Recommended:** Yes + +**Bad Example** + +```yaml +tags: + - name: "Badger" + - name: "Badger" +``` + +**Good Example** + +```yaml +tags: + - name: "Aardvark" + - name: "Badger" +``` + ### openapi-tags OpenAPI object should have non-empty `tags` array. diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-tags-uniqueness.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-tags-uniqueness.test.ts new file mode 100644 index 000000000..2c2f77ea7 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-tags-uniqueness.test.ts @@ -0,0 +1,160 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-tags-uniqueness', [ + { + name: 'valid case', + document: { + asyncapi: '2.0.0', + tags: [{ name: 'one' }, { name: 'two' }], + }, + errors: [], + }, + + { + name: 'tags has duplicated names (root)', + document: { + asyncapi: '2.0.0', + tags: [{ name: 'one' }, { name: 'one' }], + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated names (operation)', + document: { + asyncapi: '2.0.0', + channels: { + someChannel: { + publish: { + tags: [{ name: 'one' }, { name: 'one' }], + }, + subscribe: { + tags: [{ name: 'one' }, { name: 'one' }], + }, + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['channels', 'someChannel', 'publish', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"tags" object contains duplicate tag name "one".', + path: ['channels', 'someChannel', 'subscribe', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated names (operation trait)', + document: { + asyncapi: '2.0.0', + channels: { + someChannel: { + publish: { + traits: [ + { + tags: [{ name: 'one' }, { name: 'one' }], + }, + ], + }, + subscribe: { + traits: [ + { + tags: [{ name: 'one' }, { name: 'one' }], + }, + ], + }, + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['channels', 'someChannel', 'publish', 'traits', '0', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"tags" object contains duplicate tag name "one".', + path: ['channels', 'someChannel', 'subscribe', 'traits', '0', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated names (message)', + document: { + asyncapi: '2.0.0', + components: { + messages: { + someMessage: { + tags: [{ name: 'one' }, { name: 'one' }], + }, + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['components', 'messages', 'someMessage', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated names (message trait)', + document: { + asyncapi: '2.0.0', + components: { + messages: { + someMessage: { + traits: [ + { + tags: [{ name: 'one' }, { name: 'one' }], + }, + ], + }, + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['components', 'messages', 'someMessage', 'traits', '0', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated more that two times this same name', + document: { + asyncapi: '2.0.0', + tags: [{ name: 'one' }, { name: 'one' }, { name: 'two' }, { name: 'one' }], + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"tags" object contains duplicate tag name "one".', + path: ['tags', '3', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts index c32d1fd12..4ac8381d0 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts @@ -94,7 +94,7 @@ export default createRulesetFunction( input: null, options: null, }, - function oasDocumentSchema(targetVal, _, context) { + function asyncApi2DocumentSchema(targetVal, _, context) { const formats = context.document.formats; if (formats === null || formats === void 0) return; diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index 06114f22b..a683fb80b 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -13,6 +13,7 @@ import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema'; import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation'; import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation'; import asyncApi2ServerVariables from './functions/asyncApi2ServerVariables'; +import { uniquenessTags } from '../shared/functions'; export default { documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md', @@ -382,6 +383,40 @@ export default { }, }, }, + 'asyncapi-tags-uniqueness': { + description: 'Each tag must have a unique name.', + message: '{{error}}', + severity: 'error', + recommended: true, + type: 'validation', + given: [ + // root + '$.tags', + // operations + '$.channels.*.[publish,subscribe].tags', + '$.components.channels.*.[publish,subscribe].tags', + // operation traits + '$.channels.*.[publish,subscribe].traits.*.tags', + '$.components.channels.*.[publish,subscribe].traits.*.tags', + '$.components.operationTraits.*.tags', + // messages + '$.channels.*.[publish,subscribe].message.tags', + '$.channels.*.[publish,subscribe].message.oneOf.*.tags', + '$.components.channels.*.[publish,subscribe].message.tags', + '$.components.channels.*.[publish,subscribe].message.oneOf.*.tags', + '$.components.messages.*.tags', + // message traits + '$.channels.*.[publish,subscribe].message.traits.*.tags', + '$.channels.*.[publish,subscribe].message.oneOf.*.traits.*.tags', + '$.components.channels.*.[publish,subscribe].message.traits.*.tags', + '$.components.channels.*.[publish,subscribe].message.oneOf.*.traits.*.tags', + '$.components.messages.*.traits.*.tags', + '$.components.messageTraits.*.tags', + ], + then: { + function: uniquenessTags, + }, + }, 'asyncapi-tags': { description: 'AsyncAPI object must have non-empty "tags" array.', recommended: true, diff --git a/packages/rulesets/src/oas/__tests__/openapi-tags-uniqueness.test.ts b/packages/rulesets/src/oas/__tests__/openapi-tags-uniqueness.test.ts new file mode 100644 index 000000000..61e735edb --- /dev/null +++ b/packages/rulesets/src/oas/__tests__/openapi-tags-uniqueness.test.ts @@ -0,0 +1,48 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('openapi-tags-uniqueness', [ + { + name: 'valid case', + document: { + swagger: '2.0', + tags: [{ name: 'one' }, { name: 'two' }], + }, + errors: [], + }, + + { + name: 'tags has duplicated names', + document: { + swagger: '2.0', + tags: [{ name: 'one' }, { name: 'one' }], + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated more that two times this same name', + document: { + swagger: '2.0', + tags: [{ name: 'one' }, { name: 'one' }, { name: 'two' }, { name: 'one' }], + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"tags" object contains duplicate tag name "one".', + path: ['tags', '3', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/oas/index.ts b/packages/rulesets/src/oas/index.ts index 8d7e817ff..9f5c21e74 100644 --- a/packages/rulesets/src/oas/index.ts +++ b/packages/rulesets/src/oas/index.ts @@ -25,6 +25,7 @@ import { oasSchema, oasDiscriminator, } from './functions'; +import { uniquenessTags } from '../shared/functions'; export { ruleset as default }; @@ -212,6 +213,17 @@ const ruleset = { }, }, }, + 'openapi-tags-uniqueness': { + description: 'Each tag must have a unique name.', + message: '{{error}}', + severity: 'error', + recommended: true, + type: 'validation', + given: '$.tags', + then: { + function: uniquenessTags, + }, + }, 'openapi-tags': { description: 'OpenAPI object must have non-empty "tags" array.', recommended: false, diff --git a/packages/rulesets/src/shared/functions/__tests__/uniquenessTags.test.ts b/packages/rulesets/src/shared/functions/__tests__/uniquenessTags.test.ts new file mode 100644 index 000000000..5abb9a849 --- /dev/null +++ b/packages/rulesets/src/shared/functions/__tests__/uniquenessTags.test.ts @@ -0,0 +1,90 @@ +import uniquenessTags from '../uniquenessTags'; + +function runValidation(targetVal: Array<{ name: string }>) { + return uniquenessTags(targetVal, null, { path: ['tags'], documentInventory: {} } as any); +} + +describe('uniquenessTags', () => { + test('should skip empty tags', () => { + const results = runValidation([]); + expect(results).toEqual([]); + }); + + test('should skip valid tags', () => { + const tags = [ + { + name: 'one', + }, + { + name: 'two', + }, + { + name: 'three', + }, + ]; + + const results = runValidation(tags); + expect(results).toEqual([]); + }); + + test('should check 1 duplicate tags', () => { + const tags = [ + { + name: 'one', + }, + { + name: 'two', + }, + { + name: 'one', + }, + ]; + + const results = runValidation(tags); + expect(results).toEqual([ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['tags', 2, 'name'], + }, + ]); + }); + + test('should check 2 duplicate tags', () => { + const tags = [ + { + name: 'one', + }, + { + name: 'two', + }, + { + name: 'three', + }, + { + name: 'one', + }, + { + name: 'two', + }, + { + name: 'two', + }, + ]; + + const results = runValidation(tags); + expect(results).toEqual([ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['tags', 3, 'name'], + }, + { + message: '"tags" object contains duplicate tag name "two".', + path: ['tags', 4, 'name'], + }, + { + message: '"tags" object contains duplicate tag name "two".', + path: ['tags', 5, 'name'], + }, + ]); + }); +}); diff --git a/packages/rulesets/src/shared/functions/index.ts b/packages/rulesets/src/shared/functions/index.ts new file mode 100644 index 000000000..0d2f1ca8a --- /dev/null +++ b/packages/rulesets/src/shared/functions/index.ts @@ -0,0 +1 @@ +export { default as uniquenessTags } from './uniquenessTags'; diff --git a/packages/rulesets/src/shared/functions/uniquenessTags.ts b/packages/rulesets/src/shared/functions/uniquenessTags.ts new file mode 100644 index 000000000..63ebe5c11 --- /dev/null +++ b/packages/rulesets/src/shared/functions/uniquenessTags.ts @@ -0,0 +1,50 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; + +import type { IFunctionResult } from '@stoplight/spectral-core'; + +type Tags = Array<{ name: string }>; + +function getDuplicateTagsIndexes(tags: Tags): number[] { + return tags + .map(item => item.name) + .reduce((acc, item, i, arr) => { + if (arr.indexOf(item) !== i) { + acc.push(i); + } + return acc; + }, []); +} + +export default createRulesetFunction( + { + input: { + type: 'array', + items: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], + }, + }, + options: null, + }, + function uniquenessTags(targetVal, _, ctx) { + const duplicatedTags = getDuplicateTagsIndexes(targetVal); + if (duplicatedTags.length === 0) return []; + + const results: IFunctionResult[] = []; + + for (const duplicatedIndex of duplicatedTags) { + const duplicatedTag = targetVal[duplicatedIndex].name; + results.push({ + message: `"tags" object contains duplicate tag name "${duplicatedTag}".`, + path: [...ctx.path, duplicatedIndex, 'name'], + }); + } + + return results; + }, +);