Repository: stoplightio/spectral
Pull request #2104 (2022-03-22 12:15:44)

feat(rulesets): add rules for validation uniqueness of tag names
Fixes https://github.com/stoplightio/spectral/issues/2103

**Checklist**

- [X] Tests added / updated
- [X] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [X] No

**Additional context**

Part of #2100 

Add rule for validation uniqueness of tag names:

**Bad Example**

```yaml
tags:
  - name: "Badger"
  - name: "Badger"
```

**Good Example**

```yaml
tags:
  - name: "Aardvark"
  - name: "Badger"
```

magicmatatjahu: @P0lip Sorry for such a delay! All suggestions have been applied. Let me know if everything is ok and if it is ok, you can merge when you want :) Unfortunately I had to do a mixed reset + force push (it was the easiest way for me) because the commit linting was showing me errors when I merged from the Github UI `develop` branch to my PR.

Patch:
diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md
index 768b9c4a..c88945b0 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 2e4e17f3..3abcbcb4 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/functions/asyncApi2DocumentSchema.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts
index c32d1fd1..4ac8381d 100644
--- a/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts
+++ b/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts
@@ -94,7 +94,7 @@ export default createRulesetFunction<unknown, null>(
     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 06114f22..a683fb80 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/index.ts b/packages/rulesets/src/oas/index.ts
index 8d7e817f..9f5c21e7 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/index.ts b/packages/rulesets/src/shared/functions/index.ts
new file mode 100644
index 00000000..0d2f1ca8
--- /dev/null
+++ b/packages/rulesets/src/shared/functions/index.ts
@@ -0,0 +1,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 00000000..63ebe5c1
--- /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<number[]>((acc, item, i, arr) => {
+      if (arr.indexOf(item) !== i) {
+        acc.push(i);
+      }
+      return acc;
+    }, []);
+}
+
+export default createRulesetFunction<Tags, null>(
+  {
+    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;
+  },
+);


Test patch:
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 00000000..2c2f77ea
--- /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/oas/__tests__/openapi-tags-uniqueness.test.ts b/packages/rulesets/src/oas/__tests__/openapi-tags-uniqueness.test.ts
new file mode 100644
index 00000000..61e735ed
--- /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/shared/functions/__tests__/uniquenessTags.test.ts b/packages/rulesets/src/shared/functions/__tests__/uniquenessTags.test.ts
new file mode 100644
index 00000000..5abb9a84
--- /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'],
+      },
+    ]);
+  });
+});