From bcf8b7628052b7acecf022420bf3e5f1eea3a79b Mon Sep 17 00:00:00 2001 From: Phil Adams Date: Thu, 20 Jun 2024 10:04:15 -0500 Subject: [PATCH] feat: add new 'ibm-no-ref-in-example' rule (#669) This commit introduces the new 'ibm-no-ref-in-example' rule which will check to make sure that "$ref" is not used in "example" fields. Note that $ref is valid in an "examples" field, but not in an "example" field. Signed-off-by: Phil Adams --- docs/ibm-cloud-rules.md | 68 ++++ packages/ruleset/src/functions/index.js | 1 + .../src/functions/no-ref-in-example.js | 54 +++ packages/ruleset/src/ibm-oas.js | 1 + packages/ruleset/src/rules/index.js | 1 + .../ruleset/src/rules/no-ref-in-example.js | 18 + .../ruleset/test/no-ref-in-example.test.js | 319 ++++++++++++++++++ 7 files changed, 462 insertions(+) create mode 100644 packages/ruleset/src/functions/no-ref-in-example.js create mode 100644 packages/ruleset/src/rules/no-ref-in-example.js create mode 100644 packages/ruleset/test/no-ref-in-example.test.js diff --git a/docs/ibm-cloud-rules.md b/docs/ibm-cloud-rules.md index f33ea7b4..dd7158ed 100644 --- a/docs/ibm-cloud-rules.md +++ b/docs/ibm-cloud-rules.md @@ -63,7 +63,9 @@ which is delivered in the `@ibm-cloud/openapi-ruleset` NPM package. * [ibm-no-nullable-properties](#ibm-no-nullable-properties) * [ibm-no-operation-requestbody](#ibm-no-operation-requestbody) * [ibm-no-optional-properties-in-required-body](#ibm-no-optional-properties-in-required-body) + * [ibm-no-ref-in-example](#ibm-no-ref-in-example) * [ibm-no-space-in-example-name](#ibm-no-space-in-example-name) + * [ibm-no-superfluous-allof](#ibm-no-superfluous-allof) * [ibm-no-unsupported-keywords](#ibm-no-unsupported-keywords) * [ibm-openapi-tags-used](#ibm-openapi-tags-used) * [ibm-operation-responses](#ibm-operation-responses) @@ -351,6 +353,12 @@ should probably be required instead of optional. oas3 +ibm-no-ref-in-example +info +Makes sure that $ref is not used within an example field. +oas3 + + ibm-no-space-in-example-name warn The name of an entry in an examples field should not contain a space. @@ -3533,6 +3541,66 @@ paths: +### ibm-no-ref-in-example + + + + + + + + + + + + + + + + + + + + + + + + + +
Rule id:ibm-no-ref-in-example
Description:Within an OpenAPI document, the $ref field may appear within an examples field, but it is not valid within an example field. This rule checks to make sure that $ref is not used within an example field.
Severity:error
OAS Versions:oas3
Non-compliant example: +
+paths:
+  /v1/things:
+    post:
+      operationId: create_thing
+      description: Create a new Thing instance.
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/Thing'
+            example:
+              $ref: '#/components/examples/ThingExample'
+
+
Compliant example: +
+paths:
+  /v1/things:
+    post:
+      operationId: create_thing
+      description: Create a new Thing instance.
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/Thing'
+            example:
+              name: 'Thing1'
+              description: 'An example Thing object'
+
+
+ + ### ibm-no-space-in-example-name diff --git a/packages/ruleset/src/functions/index.js b/packages/ruleset/src/functions/index.js index 935d966a..9c0b1f8e 100644 --- a/packages/ruleset/src/functions/index.js +++ b/packages/ruleset/src/functions/index.js @@ -27,6 +27,7 @@ module.exports = { noAmbiguousPaths: require('./no-ambiguous-paths'), noNullableProperties: require('./no-nullable-properties'), noOperationRequestBody: require('./no-operation-requestbody'), + noRefInExample: require('./no-ref-in-example'), noSuperfluousAllOf: require('./no-superfluous-allof'), noUnsupportedKeywords: require('./no-unsupported-keywords'), operationIdCasingConvention: require('./operationid-casing-convention'), diff --git a/packages/ruleset/src/functions/no-ref-in-example.js b/packages/ruleset/src/functions/no-ref-in-example.js new file mode 100644 index 00000000..ef85a94c --- /dev/null +++ b/packages/ruleset/src/functions/no-ref-in-example.js @@ -0,0 +1,54 @@ +/** + * Copyright 2017 - 2024 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +const { isObject } = require('@ibm-cloud/openapi-ruleset-utilities'); +const { LoggerFactory } = require('../utils'); + +let ruleId; +let logger; + +module.exports = function (exampleObj, options, context) { + if (!logger) { + ruleId = context.rule.name; + logger = LoggerFactory.getInstance().getLogger(ruleId); + } + return noRefInExample(exampleObj, context.path); +}; + +/** + * This function will perform a recursive check to make sure that the specified + * "example" value does not contain a "$ref" field. + * @param {*} example the example value to check + * @param {*} path the location of 'example' within the OpenAPI document + * @returns an array containing zero or more error objects + */ +function noRefInExample(example, path) { + logger.debug(`${ruleId}: checking example located at: ${path.join('.')}`); + + // If it's not an object, then bail out now since a $ref property is not possible. + if (!isObject(example)) { + return []; + } + + const errors = []; + + // Check "example" for a $ref property. + if ('$ref' in example) { + logger.debug( + `${ruleId}: found a $ref property at location: ${path.join('.')}.$ref` + ); + errors.push({ + message: '', + path: [...path, '$ref'], + }); + } + + // Check each property of "example" recursively for an object containing a $ref property. + for (const p in example) { + errors.push(...noRefInExample(example[p], [...path, p])); + } + + return errors; +} diff --git a/packages/ruleset/src/ibm-oas.js b/packages/ruleset/src/ibm-oas.js index a4d2a537..7872af77 100644 --- a/packages/ruleset/src/ibm-oas.js +++ b/packages/ruleset/src/ibm-oas.js @@ -141,6 +141,7 @@ module.exports = { 'ibm-no-nullable-properties': ibmRules.noNullableProperties, 'ibm-no-operation-requestbody': ibmRules.noOperationRequestBody, 'ibm-no-optional-properties-in-required-body': ibmRules.optionalRequestBody, + 'ibm-no-ref-in-example': ibmRules.noRefInExample, 'ibm-no-space-in-example-name': ibmRules.examplesNameContainsSpace, 'ibm-no-superfluous-allof': ibmRules.noSuperfluousAllOf, 'ibm-no-unsupported-keywords': ibmRules.noUnsupportedKeywords, diff --git a/packages/ruleset/src/rules/index.js b/packages/ruleset/src/rules/index.js index aa37479d..18baa343 100644 --- a/packages/ruleset/src/rules/index.js +++ b/packages/ruleset/src/rules/index.js @@ -35,6 +35,7 @@ module.exports = { noAmbiguousPaths: require('./no-ambiguous-paths'), noNullableProperties: require('./no-nullable-properties'), noOperationRequestBody: require('./no-operation-requestbody'), + noRefInExample: require('./no-ref-in-example'), noSuperfluousAllOf: require('./no-superfluous-allof'), noUnsupportedKeywords: require('./no-unsupported-keywords'), operationIdCasingConvention: require('./operationid-casing-convention'), diff --git a/packages/ruleset/src/rules/no-ref-in-example.js b/packages/ruleset/src/rules/no-ref-in-example.js new file mode 100644 index 00000000..a4eab27d --- /dev/null +++ b/packages/ruleset/src/rules/no-ref-in-example.js @@ -0,0 +1,18 @@ +/** + * Copyright 2017 - 2024 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +const { oas3 } = require('@stoplight/spectral-formats'); +const { noRefInExample } = require('../functions'); +module.exports = { + description: 'The use of $ref is not valid within an example field', + message: '{{description}}', + given: ['$..example'], + severity: 'error', + formats: [oas3], + resolved: false, + then: { + function: noRefInExample, + }, +}; diff --git a/packages/ruleset/test/no-ref-in-example.test.js b/packages/ruleset/test/no-ref-in-example.test.js new file mode 100644 index 00000000..779a7bd3 --- /dev/null +++ b/packages/ruleset/test/no-ref-in-example.test.js @@ -0,0 +1,319 @@ +/** + * Copyright 2017 - 2024 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +const { noRefInExample } = require('../src/rules'); +const { makeCopy, rootDocument, testRule, severityCodes } = require('./utils'); + +const rule = noRefInExample; +const ruleId = 'ibm-no-ref-in-example'; +const expectedSeverity = severityCodes.error; +const expectedMsg = 'The use of $ref is not valid within an example field'; + +// To enable debug logging in the rule function, copy this statement to an it() block: +// LoggerFactory.getInstance().addLoggerSetting(ruleId, 'debug'); +// and uncomment this import statement: +// const LoggerFactory = require('../src/utils/logger-factory'); + +describe(`Spectral rule: ${ruleId}`, () => { + describe('Should not yield errors', () => { + it('Clean spec', async () => { + const results = await testRule(ruleId, rule, rootDocument); + expect(results).toHaveLength(0); + }); + + it('requestBody example without $ref', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].post.requestBody.content[ + 'application/json' + ].example = { + name: 'coke', + type: 'soda', + }; + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(0); + }); + + it('response example without $ref', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].post.responses['201'].content[ + 'application/json' + ].example = { + name: 'coke', + type: 'soda', + }; + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(0); + }); + + it('schema example without $ref', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.components.schemas.Drink.example = { + name: 'coke', + type: 'soda', + }; + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(0); + }); + + it('parameter example without $ref (schema)', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].get.parameters[0].example = 'start1'; + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(0); + }); + it('parameter example without $ref (content)', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].get.parameters[0] = { + in: 'query', + name: 'start', + content: { + 'application/json': { + schema: { + type: 'string', + }, + example: 'start1', + }, + }, + }; + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(0); + }); + }); + + describe('Should yield errors', () => { + it('requestBody example with $ref', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].post.requestBody.content[ + 'application/json' + ].example = { + $ref: '#/components/schemas/Error', + }; + + const expectedPaths = [ + 'paths./v1/drinks.post.requestBody.content.application/json.example.$ref', + ]; + + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(1); + for (const i in results) { + expect(results[i].code).toBe(ruleId); + expect(results[i].message).toBe(expectedMsg); + expect(results[i].severity).toBe(expectedSeverity); + expect(results[i].path.join('.')).toBe(expectedPaths[i]); + } + }); + + it('response example with $ref', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].post.responses['201'].content[ + 'application/json' + ].example = { + $ref: '#/components/schemas/Error', + }; + + const expectedPaths = [ + 'paths./v1/drinks.post.responses.201.content.application/json.example.$ref', + ]; + + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(1); + for (const i in results) { + expect(results[i].code).toBe(ruleId); + expect(results[i].message).toBe(expectedMsg); + expect(results[i].severity).toBe(expectedSeverity); + expect(results[i].path.join('.')).toBe(expectedPaths[i]); + } + }); + + it('schema example with $ref', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.components.schemas.Drink.example = { + $ref: '#/components/schemas/Error', + }; + + const expectedPaths = ['components.schemas.Drink.example.$ref']; + + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(1); + for (const i in results) { + expect(results[i].code).toBe(ruleId); + expect(results[i].message).toBe(expectedMsg); + expect(results[i].severity).toBe(expectedSeverity); + expect(results[i].path.join('.')).toBe(expectedPaths[i]); + } + }); + + it('parameter example with $ref (schema)', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].get.parameters[0].example = { + $ref: '#/components/schemas/Error', + }; + + const expectedPaths = ['paths./v1/drinks.get.parameters.0.example.$ref']; + + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(1); + for (const i in results) { + expect(results[i].code).toBe(ruleId); + expect(results[i].message).toBe(expectedMsg); + expect(results[i].severity).toBe(expectedSeverity); + expect(results[i].path.join('.')).toBe(expectedPaths[i]); + } + }); + + it('parameter example with $ref (content)', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].get.parameters[0] = { + in: 'query', + name: 'start', + content: { + 'application/json': { + schema: { + type: 'string', + }, + example: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }; + + const expectedPaths = [ + 'paths./v1/drinks.get.parameters.0.content.application/json.example.$ref', + ]; + + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(1); + for (const i in results) { + expect(results[i].code).toBe(ruleId); + expect(results[i].message).toBe(expectedMsg); + expect(results[i].severity).toBe(expectedSeverity); + expect(results[i].path.join('.')).toBe(expectedPaths[i]); + } + }); + + it('requestBody example with nested $ref', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].post.requestBody.content[ + 'application/json' + ].example = { + name: 'Root Beer', + type: 'soda', + nested_stuff: { + $ref: '#/components/schemas/Error', + }, + }; + + const expectedPaths = [ + 'paths./v1/drinks.post.requestBody.content.application/json.example.nested_stuff.$ref', + ]; + + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(1); + for (const i in results) { + expect(results[i].code).toBe(ruleId); + expect(results[i].message).toBe(expectedMsg); + expect(results[i].severity).toBe(expectedSeverity); + expect(results[i].path.join('.')).toBe(expectedPaths[i]); + } + }); + + it('response example with nested $ref', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].post.responses['201'].content[ + 'application/json' + ].example = { + name: 'Root Beer', + type: 'soda', + stuff: { + nested_stuff: { + more_nested_stuff: { + $ref: '#/components/schemas/Error', + }, + }, + foo: 'bar', + }, + }; + + const expectedPaths = [ + 'paths./v1/drinks.post.responses.201.content.application/json.example.stuff.nested_stuff.more_nested_stuff.$ref', + ]; + + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(1); + for (const i in results) { + expect(results[i].code).toBe(ruleId); + expect(results[i].message).toBe(expectedMsg); + expect(results[i].severity).toBe(expectedSeverity); + expect(results[i].path.join('.')).toBe(expectedPaths[i]); + } + }); + + it('parameter example with nested $ref (content)', async () => { + const testDocument = makeCopy(rootDocument); + + testDocument.paths['/v1/drinks'].get.parameters[0] = { + in: 'query', + name: 'start', + content: { + 'application/json': { + schema: { + type: 'object', + }, + example: { + name: 'Root Beer', + type: 'soda', + $ref: '#/components/schemas/Error', + stuff: { + nested_stuff: { + more_nested_stuff: { + $ref: '#/components/schemas/Error', + }, + }, + foo: { + bar: 'baz', + $ref: { + $ref: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + }, + }; + + const expectedPaths = [ + 'paths./v1/drinks.get.parameters.0.content.application/json.example.$ref', + 'paths./v1/drinks.get.parameters.0.content.application/json.example.stuff.nested_stuff.more_nested_stuff.$ref', + 'paths./v1/drinks.get.parameters.0.content.application/json.example.stuff.foo.$ref', + 'paths./v1/drinks.get.parameters.0.content.application/json.example.stuff.foo.$ref.$ref', + 'paths./v1/drinks.get.parameters.0.content.application/json.example.stuff.foo.$ref.$ref.$ref', + ]; + + const results = await testRule(ruleId, rule, testDocument); + expect(results).toHaveLength(5); + for (const i in results) { + expect(results[i].code).toBe(ruleId); + expect(results[i].message).toBe(expectedMsg); + expect(results[i].severity).toBe(expectedSeverity); + expect(results[i].path.join('.')).toBe(expectedPaths[i]); + } + }); + }); +});