diff --git a/docs/reference/openapi-rules.md b/docs/reference/openapi-rules.md index 0bd9966e2..6fa1411d2 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/__tests__/linter.test.ts b/src/__tests__/linter.test.ts index 3788ecd10..bb41b6430 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 000000000..58d161827 --- /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/src/rulesets/oas/functions/oasTagDefined.ts b/src/rulesets/oas/functions/oasTagDefined.ts new file mode 100644 index 000000000..a13575db5 --- /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 = (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 4a5c40e50..f82c196ca 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 f2cd16fa6..aeb7f2495 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, }; }; diff --git a/test-harness/scenarios/enabled-rules-amount.oas3.scenario b/test-harness/scenarios/enabled-rules-amount.oas3.scenario index 24b067fe3..1f3474388 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 d752660a6..fc264921b 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 1f0efe029..71c59564a 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)