Repository: stoplightio/spectral
Pull request #704 (2019-10-23 18:12:59)

Add rule to detect operation tags that are not defined in global tags object
This PR adds a new operation validation that reports operation tag values that are not defined in the global tags array.

This new rule is marked as "recommended" and by default produces a warning message.

- [X] Tests added / updated
- [?] Docs added / updated

Note: "CONTRIBUTING.md" does not say anything about  doc updates, so I'm not quite sure what I need to do there.

**Does this PR introduce a breaking change?**

- [ ] Yes
- [X] No



Patch:
diff --git a/docs/reference/openapi-rules.md b/docs/reference/openapi-rules.md
index 0bd9966e..6fa1411d 100644
--- a/docs/reference/openapi-rules.md
+++ b/docs/reference/openapi-rules.md
@@ -334,6 +334,12 @@ Operation should have non-empty `tags` array.
 
 **Recommended:** Yes
 
+### operation-tag-defined
+
+Operation tags should be defined in global tags.
+
+**Recommended:** Yes
+
 ### path-declarations-must-exist
 
 Path parameter declarations cannot be empty, ex.`/given/{}` is invalid.
@@ -568,4 +574,4 @@ Validate structure of OpenAPI v3 specification.
 
 Parameter objects should have a `description`.
 
-**Recommended:** No
\ No newline at end of file
+**Recommended:** No
diff --git a/src/rulesets/oas/functions/oasTagDefined.ts b/src/rulesets/oas/functions/oasTagDefined.ts
new file mode 100644
index 00000000..a13575db
--- /dev/null
+++ b/src/rulesets/oas/functions/oasTagDefined.ts
@@ -0,0 +1,36 @@
+// This function will check an API doc to verify that any tag that appears on
+// an operation is also present in the global tags array.
+
+import { IFunction, IFunctionResult, Rule } from '../../../types';
+
+export const oasTagDefined: IFunction<Rule> = (targetVal, _options, functionPaths) => {
+  const results: IFunctionResult[] = [];
+
+  const globalTags = (targetVal.tags || []).map(({ name }: { name: string }) => name);
+
+  const { paths = {} } = targetVal;
+
+  const validOperationKeys = ['get', 'head', 'post', 'put', 'patch', 'delete', 'options', 'trace'];
+
+  for (const path in paths) {
+    if (Object.keys(paths[path]).length > 0) {
+      for (const operation in paths[path]) {
+        if (validOperationKeys.indexOf(operation) > -1) {
+          const { tags = [] } = paths[path][operation];
+          tags.forEach((tag: string, index: number) => {
+            if (globalTags.indexOf(tag) === -1) {
+              results.push({
+                message: 'Operation tags should be defined in global tags.',
+                path: ['paths', path, operation, 'tags', index],
+              });
+            }
+          });
+        }
+      }
+    }
+  }
+
+  return results;
+};
+
+export default oasTagDefined;
diff --git a/src/rulesets/oas/index.json b/src/rulesets/oas/index.json
index 4a5c40e5..f82c196c 100644
--- a/src/rulesets/oas/index.json
+++ b/src/rulesets/oas/index.json
@@ -8,6 +8,7 @@
     "oasOpParams",
     "oasOpSecurityDefined",
     "oasPathParam",
+    "oasTagDefined",
     "refSiblings"
   ],
   "rules": {
@@ -61,6 +62,18 @@
         "operation"
       ]
     },
+    "operation-tag-defined": {
+      "description": "Operation tags should be defined in global tags.",
+      "recommended": true,
+      "type": "validation",
+      "given": "$",
+      "then": {
+        "function": "oasTagDefined"
+      },
+      "tags": [
+        "operation"
+      ]
+    },
     "path-params": {
       "description": "Path parameters should be defined and valid.",
       "message": "{{error}}",
diff --git a/src/rulesets/oas/index.ts b/src/rulesets/oas/index.ts
index f2cd16fa..aeb7f249 100644
--- a/src/rulesets/oas/index.ts
+++ b/src/rulesets/oas/index.ts
@@ -10,6 +10,7 @@ export const commonOasFunctions = (): FunctionCollection => {
     oasOpIdUnique: require('./functions/oasOpIdUnique').oasOpIdUnique,
     oasOpFormDataConsumeCheck: require('./functions/oasOpFormDataConsumeCheck').oasOpFormDataConsumeCheck,
     oasOpParams: require('./functions/oasOpParams').oasOpParams,
+    oasTagDefined: require('./functions/oasTagDefined').oasTagDefined,
     refSiblings: require('./functions/refSiblings').refSiblings,
   };
 };


Test patch:
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index 3788ecd1..bb41b643 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -547,6 +547,9 @@ responses:: !!foo
       expect.objectContaining({
         code: 'invalid-ref',
       }),
+      expect.objectContaining({
+        code: 'operation-tag-defined',
+      }),
       expect.objectContaining({
         code: 'valid-example-in-schemas',
         message: '"foo.example" property type should be number',
@@ -611,6 +614,12 @@ responses:: !!foo
 
   test('should support YAML merge keys', async () => {
     await spectral.loadRuleset('spectral:oas3');
+    spectral.setRules({
+      'operation-tag-defined': {
+        ...spectral.rules['operation-tag-defined'],
+        severity: 'off',
+      },
+    });
 
     const result = await spectral.run(petstoreMergeKeys);
 
diff --git a/src/rulesets/oas/functions/__tests__/oasTagDefined.test.ts b/src/rulesets/oas/functions/__tests__/oasTagDefined.test.ts
new file mode 100644
index 00000000..58d16182
--- /dev/null
+++ b/src/rulesets/oas/functions/__tests__/oasTagDefined.test.ts
@@ -0,0 +1,153 @@
+import { RuleType, Spectral } from '../../../../index';
+
+import { DiagnosticSeverity } from '@stoplight/types';
+import { rules } from '../../index.json';
+import oasTagDefined from '../oasTagDefined';
+
+describe('oasTagDefined', () => {
+  const s = new Spectral();
+
+  s.setFunctions({ oasTagDefined });
+  s.setRules({
+    'operation-tag-defined': Object.assign(rules['operation-tag-defined'], {
+      recommended: true,
+      type: RuleType[rules['operation-tag-defined'].type],
+    }),
+  });
+
+  test('validate a correct object', async () => {
+    const results = await s.run({
+      tags: [
+        {
+          name: 'tag1',
+        },
+        {
+          name: 'tag2',
+        },
+      ],
+      paths: {
+        '/path1': {
+          get: {
+            tags: ['tag1'],
+          },
+        },
+        '/path2': {
+          get: {
+            tags: ['tag2'],
+          },
+        },
+      },
+    });
+    expect(results.length).toEqual(0);
+  });
+
+  test('return errors on undefined tag', async () => {
+    const results = await s.run({
+      tags: [
+        {
+          name: 'tag1',
+        },
+      ],
+      paths: {
+        '/path1': {
+          get: {
+            tags: ['tag2'],
+          },
+        },
+      },
+    });
+
+    expect(results).toEqual([
+      {
+        code: 'operation-tag-defined',
+        message: 'Operation tags should be defined in global tags.',
+        path: ['paths', '/path1', 'get', 'tags', '0'],
+        range: {
+          end: {
+            character: 16,
+            line: 10,
+          },
+          start: {
+            character: 10,
+            line: 10,
+          },
+        },
+        severity: DiagnosticSeverity.Warning,
+      },
+    ]);
+  });
+
+  test('return errors on undefined tags among defined tags', async () => {
+    const results = await s.run({
+      tags: [
+        {
+          name: 'tag1',
+        },
+        {
+          name: 'tag3',
+        },
+      ],
+      paths: {
+        '/path1': {
+          get: {
+            tags: ['tag1', 'tag2', 'tag3', 'tag4'],
+          },
+        },
+      },
+    });
+
+    expect(results).toEqual([
+      {
+        code: 'operation-tag-defined',
+        message: 'Operation tags should be defined in global tags.',
+        path: ['paths', '/path1', 'get', 'tags', '1'],
+        range: {
+          end: {
+            character: 16,
+            line: 14,
+          },
+          start: {
+            character: 10,
+            line: 14,
+          },
+        },
+        severity: DiagnosticSeverity.Warning,
+      },
+      {
+        code: 'operation-tag-defined',
+        message: 'Operation tags should be defined in global tags.',
+        path: ['paths', '/path1', 'get', 'tags', '3'],
+        range: {
+          end: {
+            character: 16,
+            line: 16,
+          },
+          start: {
+            character: 10,
+            line: 16,
+          },
+        },
+        severity: DiagnosticSeverity.Warning,
+      },
+    ]);
+  });
+
+  test('resilient to no global tags or operation tags', async () => {
+    const results = await s.run({
+      paths: {
+        '/path1': {
+          get: {
+            operationId: 'id1',
+          },
+        },
+        '/path2': {
+          get: {
+            operationId: 'id2',
+          },
+        },
+      },
+    });
+
+    expect(results.length).toEqual(0);
+  });
+});
diff --git a/test-harness/scenarios/enabled-rules-amount.oas3.scenario b/test-harness/scenarios/enabled-rules-amount.oas3.scenario
index 24b067fe..1f347438 100644
--- a/test-harness/scenarios/enabled-rules-amount.oas3.scenario
+++ b/test-harness/scenarios/enabled-rules-amount.oas3.scenario
@@ -24,7 +24,7 @@ components:
 ====command====
 lint {document} --ruleset ./test-harness/scenarios/rulesets/parameter-description.oas3.yaml -v
 ====stdout====
-Found 55 rules (1 enabled)
+Found 56 rules (1 enabled)
 Linting {document}
 OpenAPI 3.x detected
 
diff --git a/test-harness/scenarios/severity/display-warnings.oas3.scenario b/test-harness/scenarios/severity/display-warnings.oas3.scenario
index d752660a..fc264921 100644
--- a/test-harness/scenarios/severity/display-warnings.oas3.scenario
+++ b/test-harness/scenarios/severity/display-warnings.oas3.scenario
@@ -1,5 +1,5 @@
 ====test====
-Fail severity is set to error but only warnings exist, 
+Fail severity is set to error but only warnings exist,
 so status should be success and output should show warnings
 ====document====
 openapi: '3.0.0'
@@ -47,9 +47,10 @@ lint {document} --fail-severity=error
 OpenAPI 3.x detected
 
 {document}
- 1:1  warning  api-servers            OpenAPI `servers` must be present and non-empty array.
- 2:6  warning  info-contact           Info object should contain `contact` object.
- 2:6  warning  info-description       OpenAPI object info `description` must be present and non-empty string.
- 9:9  warning  operation-description  Operation `description` must be present and non-empty string.
+  1:1   warning  api-servers            OpenAPI `servers` must be present and non-empty array.
+  2:6   warning  info-contact           Info object should contain `contact` object.
+  2:6   warning  info-description       OpenAPI object info `description` must be present and non-empty string.
+  9:9   warning  operation-description  Operation `description` must be present and non-empty string.
+ 13:11  warning  operation-tag-defined  Operation tags should be defined in global tags.
 
-✖ 4 problems (0 errors, 4 warnings, 0 infos, 0 hints)
+✖ 5 problems (0 errors, 5 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/severity/stylish-display-proper-names.scenario b/test-harness/scenarios/severity/stylish-display-proper-names.scenario
index 1f0efe02..71c59564 100644
--- a/test-harness/scenarios/severity/stylish-display-proper-names.scenario
+++ b/test-harness/scenarios/severity/stylish-display-proper-names.scenario
@@ -94,8 +94,10 @@ OpenAPI 3.x detected
   3:10      warning  info-description        OpenAPI object info `description` must be present and non-empty string.
   5:14         hint  info-matches-stoplight  Info must contain Stoplight
  12:13  information  operation-description   Operation `description` must be present and non-empty string.
+ 15:18      warning  operation-tag-defined   Operation tags should be defined in global tags.
  42:27        error  invalid-ref             '#/components/schemas/Pets' does not exist
  52:27        error  invalid-ref             '#/components/schemas/Error' does not exist
  59:14  information  operation-description   Operation `description` must be present and non-empty string.
+ 62:18      warning  operation-tag-defined   Operation tags should be defined in global tags.
 
-✖ 8 problems (3 errors, 2 warnings, 2 infos, 1 hint)
+✖ 10 problems (3 errors, 4 warnings, 2 infos, 1 hint)