Repository: Redocly/redocly-cli
Pull request #800 (2022-08-09 09:24:28)

feat: add allowAdditionalProperties option to rules
## What/Why/How?

- Add allowAdditionalProperties to built in rules
- Mark disallowAdditionalProperties as deprecated

## Reference

Close: https://github.com/Redocly/redocly-cli/issues/793

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [ ] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 68.49%     |    2871/4192    |
|                                                                           🟡                                                                           | Branches   | 61.82%     |    1585/2564    |
|                                                                           🟡                                                                           | Functions  | 60.05%     |     460/766     |
|                                                                           🟡                                                                           | Lines      | 68.3%      |    2661/3896    |


<details><summary>Show files with reduced coverage 🔻</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                        | Statements | Branches                                                       | Functions                                                   | Lines                                                          |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------- | :--------- | :------------------------------------------------------------- | :---------------------------------------------------------- | :------------------------------------------------------------- |
|                                                                           🟢                                                                           | <div title="core/src/rules/utils.ts">`...` / utils.ts</div> | 92.98%     | <div title="Base coverage is: 91.67%">88.46% (-3.21% 🔻)</div> | <div title="Base coverage is: 88.89%">90% (+1.11% 🔼)</div> | <div title="Base coverage is: 95.56%">94.34% (-1.22% 🔻)</div> |

</details>

## Test suite run success
431 tests passing in 75 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 649f4961fc0b161be8aeddeb373eccf8d7f6b1aa</p>


Patch:
diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts
index f19e6ea2..49241fc8 100644
--- a/packages/core/src/config/minimal.ts
+++ b/packages/core/src/config/minimal.ts
@@ -36,7 +36,7 @@ export default {
   oas3_0Rules: {
     'no-invalid-media-type-examples': {
       severity: 'warn',
-      disallowAdditionalProperties: true,
+      allowAdditionalProperties: false,
     },
     'no-server-example.com': 'warn',
     'no-server-trailing-slash': 'error',
@@ -47,6 +47,7 @@ export default {
     'no-servers-empty-enum': 'error',
   },
   oas3_1Rules: {
+    'no-invalid-media-type-examples': 'warn',
     'no-server-example.com': 'warn',
     'no-server-trailing-slash': 'error',
     'no-empty-servers': 'warn',
diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts
index 7285c7b8..56066405 100644
--- a/packages/core/src/config/recommended.ts
+++ b/packages/core/src/config/recommended.ts
@@ -36,7 +36,7 @@ export default {
   oas3_0Rules: {
     'no-invalid-media-type-examples': {
       severity: 'warn',
-      disallowAdditionalProperties: true,
+      allowAdditionalProperties: false,
     },
     'no-server-example.com': 'warn',
     'no-server-trailing-slash': 'error',
@@ -47,6 +47,7 @@ export default {
     'no-servers-empty-enum': 'error',
   },
   oas3_1Rules: {
+    'no-invalid-media-type-examples': 'warn',
     'no-server-example.com': 'warn',
     'no-server-trailing-slash': 'error',
     'no-empty-servers': 'error',
diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts
index ac177feb..9a0a040f 100644
--- a/packages/core/src/config/utils.ts
+++ b/packages/core/src/config/utils.ts
@@ -1,5 +1,9 @@
-import { green, yellow } from 'colorette';
-import { assignExisting } from '../utils';
+import { yellow } from 'colorette';
+import {
+  assignExisting,
+  showErrorForDeprecatedField,
+  showWarningForDeprecatedField,
+} from '../utils';
 import { Config } from './config';
 
 import type {
@@ -171,15 +175,11 @@ function checkForDeprecatedFields(
     );
 
   if (rawConfig[deprecatedField] && rawConfig[updatedField]) {
-    throw new Error(`Do not use '${deprecatedField}' field. Use '${updatedField}' instead.\n`);
+    showErrorForDeprecatedField(deprecatedField, updatedField);
   }
 
   if (rawConfig[deprecatedField] || isDeprecatedFieldInApis) {
-    process.stderr.write(
-      `The ${yellow(deprecatedField)} field is deprecated. Use ${green(
-        updatedField
-      )} instead. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties\n`
-    );
+    showWarningForDeprecatedField(deprecatedField, updatedField);
   }
 }
 
diff --git a/packages/core/src/rules/ajv.ts b/packages/core/src/rules/ajv.ts
index 6e35729e..9799c7ba 100644
--- a/packages/core/src/rules/ajv.ts
+++ b/packages/core/src/rules/ajv.ts
@@ -8,7 +8,7 @@ export function releaseAjvInstance() {
   ajvInstance = null;
 }
 
-function getAjv(resolve: ResolveFn, disallowAdditionalProperties: boolean) {
+function getAjv(resolve: ResolveFn, allowAdditionalProperties: boolean) {
   if (!ajvInstance) {
     ajvInstance = new Ajv({
       schemaId: '$id',
@@ -20,7 +20,7 @@ function getAjv(resolve: ResolveFn, disallowAdditionalProperties: boolean) {
       discriminator: true,
       allowUnionTypes: true,
       validateFormats: false, // TODO: fix it
-      defaultAdditionalProperties: !disallowAdditionalProperties,
+      defaultAdditionalProperties: allowAdditionalProperties,
       loadSchemaSync(base: string, $ref: string) {
         const resolvedRef = resolve({ $ref }, base.split('#')[0]);
         if (!resolvedRef || !resolvedRef.location) return false;
@@ -36,9 +36,9 @@ function getAjvValidator(
   schema: any,
   loc: Location,
   resolve: ResolveFn,
-  disallowAdditionalProperties: boolean
+  allowAdditionalProperties: boolean
 ): ValidateFunction | undefined {
-  const ajv = getAjv(resolve, disallowAdditionalProperties);
+  const ajv = getAjv(resolve, allowAdditionalProperties);
 
   if (!ajv.getSchema(loc.absolutePointer)) {
     ajv.addSchema({ $id: loc.absolutePointer, ...schema }, loc.absolutePointer);
@@ -53,9 +53,9 @@ export function validateJsonSchema(
   schemaLoc: Location,
   instancePath: string,
   resolve: ResolveFn,
-  disallowAdditionalProperties: boolean
+  allowAdditionalProperties: boolean
 ): { valid: boolean; errors: (ErrorObject & { suggest?: string[] })[] } {
-  const validate = getAjvValidator(schema, schemaLoc, resolve, disallowAdditionalProperties);
+  const validate = getAjvValidator(schema, schemaLoc, resolve, allowAdditionalProperties);
   if (!validate) return { valid: true, errors: [] }; // unresolved refs are reported
 
   const valid = validate(data, {
diff --git a/packages/core/src/rules/common/no-invalid-parameter-examples.ts b/packages/core/src/rules/common/no-invalid-parameter-examples.ts
index fc33b7fa..bea8e5bc 100644
--- a/packages/core/src/rules/common/no-invalid-parameter-examples.ts
+++ b/packages/core/src/rules/common/no-invalid-parameter-examples.ts
@@ -1,9 +1,9 @@
 import { UserContext } from '../../walk';
 import { Oas3Parameter } from '../../typings/openapi';
-import { validateExample } from '../utils';
+import { getAdditionalPropertiesOption, validateExample } from '../utils';
 
 export const NoInvalidParameterExamples: any = (opts: any) => {
-  const disallowAdditionalProperties = opts.disallowAdditionalProperties ?? true;
+  const allowAdditionalProperties = getAdditionalPropertiesOption(opts) ?? false;
   return {
     Parameter: {
       leave(parameter: Oas3Parameter, ctx: UserContext) {
@@ -13,7 +13,7 @@ export const NoInvalidParameterExamples: any = (opts: any) => {
             parameter.schema!,
             ctx.location.child('example'),
             ctx,
-            disallowAdditionalProperties
+            allowAdditionalProperties
           );
         }
 
@@ -25,7 +25,7 @@ export const NoInvalidParameterExamples: any = (opts: any) => {
                 parameter.schema!,
                 ctx.location.child(['examples', key]),
                 ctx,
-                false
+                true
               );
             }
           }
diff --git a/packages/core/src/rules/common/no-invalid-schema-examples.ts b/packages/core/src/rules/common/no-invalid-schema-examples.ts
index 0e0f06f9..a80d5c4c 100644
--- a/packages/core/src/rules/common/no-invalid-schema-examples.ts
+++ b/packages/core/src/rules/common/no-invalid-schema-examples.ts
@@ -1,9 +1,9 @@
 import { UserContext } from '../../walk';
 import { Oas3_1Schema } from '../../typings/openapi';
-import { validateExample } from '../utils';
+import { getAdditionalPropertiesOption, validateExample } from '../utils';
 
 export const NoInvalidSchemaExamples: any = (opts: any) => {
-  const disallowAdditionalProperties = opts.disallowAdditionalProperties ?? true;
+  const allowAdditionalProperties = getAdditionalPropertiesOption(opts) ?? false;
   return {
     Schema: {
       leave(schema: Oas3_1Schema, ctx: UserContext) {
@@ -14,12 +14,12 @@ export const NoInvalidSchemaExamples: any = (opts: any) => {
               schema,
               ctx.location.child(['examples', schema.examples.indexOf(example)]),
               ctx,
-              disallowAdditionalProperties
+              allowAdditionalProperties
             );
           }
         }
         if (schema.example) {
-          validateExample(schema.example, schema, ctx.location.child('example'), ctx, false);
+          validateExample(schema.example, schema, ctx.location.child('example'), ctx, true);
         }
       },
     },
diff --git a/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts b/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts
index 8ac79e17..62a72303 100644
--- a/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts
+++ b/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts
@@ -1,11 +1,11 @@
 import { Oas3Rule } from '../../visitors';
 import { Location, isRef } from '../../ref-utils';
 import { Oas3Example } from '../../typings/openapi';
-import { validateExample } from '../utils';
+import { getAdditionalPropertiesOption, validateExample } from '../utils';
 import { UserContext } from '../../walk';
 
 export const ValidContentExamples: Oas3Rule = (opts) => {
-  const disallowAdditionalProperties = opts.disallowAdditionalProperties ?? true;
+  const allowAdditionalProperties = getAdditionalPropertiesOption(opts) ?? false;
 
   return {
     MediaType: {
@@ -40,7 +40,7 @@ export const ValidContentExamples: Oas3Rule = (opts) => {
             mediaType.schema!,
             location,
             ctx,
-            disallowAdditionalProperties
+            allowAdditionalProperties
           );
         }
       },
diff --git a/packages/core/src/rules/utils.ts b/packages/core/src/rules/utils.ts
index a621d429..504810c3 100644
--- a/packages/core/src/rules/utils.ts
+++ b/packages/core/src/rules/utils.ts
@@ -3,6 +3,7 @@ import { UserContext } from '../walk';
 import { Location } from '../ref-utils';
 import { validateJsonSchema } from './ajv';
 import { Oas3Schema, Referenced } from '../typings/openapi';
+import { showErrorForDeprecatedField, showWarningForDeprecatedField } from '../utils';
 
 export function oasTypeOf(value: unknown) {
   if (Array.isArray(value)) {
@@ -91,7 +92,7 @@ export function validateExample(
   schema: Referenced<Oas3Schema>,
   dataLoc: Location,
   { resolve, location, report }: UserContext,
-  disallowAdditionalProperties: boolean
+  allowAdditionalProperties: boolean
 ) {
   try {
     const { valid, errors } = validateJsonSchema(
@@ -100,7 +101,7 @@ export function validateExample(
       location.child('schema'),
       dataLoc.pointer,
       resolve,
-      disallowAdditionalProperties
+      allowAdditionalProperties
     );
     if (!valid) {
       for (let error of errors) {
@@ -123,3 +124,16 @@ export function validateExample(
     });
   }
 }
+
+export function getAdditionalPropertiesOption(opts: Record<string, any>): boolean {
+  if (opts.disallowAdditionalProperties === undefined) {
+    return opts.allowAdditionalProperties;
+  }
+
+  if (opts.allowAdditionalProperties !== undefined) {
+    showErrorForDeprecatedField('disallowAdditionalProperties', 'allowAdditionalProperties');
+  }
+
+  showWarningForDeprecatedField('disallowAdditionalProperties', 'allowAdditionalProperties');
+  return !opts.disallowAdditionalProperties;
+}
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 4ea93d42..c3b3c685 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -7,6 +7,7 @@ import { parseYaml } from './js-yaml';
 import { UserContext } from './walk';
 import { HttpResolveConfig } from './config';
 import { env } from './config';
+import { green, yellow } from 'colorette';
 
 export { parseYaml, stringifyYaml } from './js-yaml';
 
@@ -204,3 +205,15 @@ export function doesYamlFileExist(filePath: string): boolean {
     fs.existsSync(filePath)
   );
 }
+
+export function showWarningForDeprecatedField(deprecatedField: string, updatedField: string) {
+  process.stderr.write(
+    `The ${yellow(deprecatedField)} field is deprecated. Use ${green(
+      updatedField
+    )} instead. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties\n`
+  );
+}
+
+export function showErrorForDeprecatedField(deprecatedField: string, updatedField: string) {
+  throw new Error(`Do not use '${deprecatedField}' field. Use '${updatedField}' instead.\n`);
+}


Test patch:
diff --git a/__tests__/lint/assertions-error/snapshot.js b/__tests__/lint/assertions-error/snapshot.js
index 6461f5af..da51a815 100644
--- a/__tests__/lint/assertions-error/snapshot.js
+++ b/__tests__/lint/assertions-error/snapshot.js
@@ -335,9 +335,25 @@ Tag object should contain \`description\` field.
 Warning was generated by the tag-description rule.
 
 
+[23] openapi.yaml:77:19 at #/paths/~1pet~1findByStatus/post/responses/404/content/application~1pdf/examples/credit-file-identity-address/value
+
+Example value must conform to the schema: type must be string.
+
+75 |         examples:
+76 |           credit-file-identity-address:
+77 |             summary: Credit file with fallback
+   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+78 | x-code-samples:
+79 |   - lang: 'C#'
+
+referenced from openapi.yaml:73:15
+
+Warning was generated by the no-invalid-media-type-examples rule.
+
+
 /openapi.yaml: validated in <test>ms
 
-❌ Validation failed with 21 errors and 1 warning.
+❌ Validation failed with 21 errors and 2 warnings.
 run \`openapi lint --generate-ignore-file\` to add all problems to the ignore file.
 
 
diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
index a56a87d7..b9b738e2 100644
--- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
+++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
@@ -24,6 +24,7 @@ Object {
   "oas3_1Rules": Object {
     "no-empty-servers": "error",
     "no-example-value-and-externalValue": "error",
+    "no-invalid-media-type-examples": "error",
     "no-server-example.com": "warn",
     "no-server-trailing-slash": "error",
     "no-servers-empty-enum": "error",
@@ -94,6 +95,7 @@ Object {
   "oas3_1Rules": Object {
     "no-empty-servers": "error",
     "no-example-value-and-externalValue": "error",
+    "no-invalid-media-type-examples": "warn",
     "no-server-example.com": "warn",
     "no-server-trailing-slash": "error",
     "no-servers-empty-enum": "error",
diff --git a/packages/core/src/rules/__tests__/utils.test.ts b/packages/core/src/rules/__tests__/utils.test.ts
index 17417ace..8db6991b 100644
--- a/packages/core/src/rules/__tests__/utils.test.ts
+++ b/packages/core/src/rules/__tests__/utils.test.ts
@@ -1,4 +1,10 @@
-import { fieldNonEmpty, matchesJsonSchemaType, missingRequiredField, oasTypeOf } from '../utils';
+import {
+  fieldNonEmpty,
+  matchesJsonSchemaType,
+  missingRequiredField,
+  oasTypeOf,
+  getAdditionalPropertiesOption,
+} from '../utils';
 
 describe('field-non-empty', () => {
   it('should match expected message', () => {
@@ -120,3 +126,35 @@ describe('oas-type-of', () => {
     expect(results).toBe('object');
   });
 });
+
+describe('get-additional-properties-option', () => {
+  it('should return actual option', () => {
+    const options = {
+      allowAdditionalProperties: true,
+    };
+    expect(getAdditionalPropertiesOption(options)).toBeTruthy();
+  });
+
+  it('should reverse option', () => {
+    const options = {
+      disallowAdditionalProperties: true,
+    };
+    expect(getAdditionalPropertiesOption(options)).toBeFalsy();
+  });
+
+  it('should throw error with message', () => {
+    const options = {
+      allowAdditionalProperties: true,
+      disallowAdditionalProperties: false,
+    };
+
+    try {
+      getAdditionalPropertiesOption(options);
+    } catch (error) {
+      expect(error).toBeInstanceOf(Error);
+      expect(error.message).toEqual(
+        "Do not use 'disallowAdditionalProperties' field. Use 'allowAdditionalProperties' instead.\n"
+      );
+    }
+  });
+});
diff --git a/packages/core/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts b/packages/core/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts
index cf149543..a1b6f8e1 100644
--- a/packages/core/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts
+++ b/packages/core/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts
@@ -76,7 +76,7 @@ describe('no-invalid-media-type-examples', () => {
     `);
   });
 
-  it('should report on invalid example with disallowAdditionalProperties', async () => {
+  it('should report on invalid example with allowAdditionalProperties', async () => {
     const document = parseYamlToDocument(
       outdent`
         openapi: 3.0.0
@@ -109,7 +109,7 @@ describe('no-invalid-media-type-examples', () => {
       config: await makeConfig({
         'no-invalid-media-type-examples': {
           severity: 'error',
-          disallowAdditionalProperties: true,
+          allowAdditionalProperties: false,
         },
       }),
     });
@@ -137,7 +137,7 @@ describe('no-invalid-media-type-examples', () => {
     `);
   });
 
-  it('should not on invalid example with disallowAdditionalProperties', async () => {
+  it('should not on invalid example with allowAdditionalProperties', async () => {
     const document = parseYamlToDocument(
       outdent`
         openapi: 3.0.0
@@ -169,7 +169,7 @@ describe('no-invalid-media-type-examples', () => {
       config: await makeConfig({
         'no-invalid-media-type-examples': {
           severity: 'error',
-          disallowAdditionalProperties: true,
+          allowAdditionalProperties: false,
         },
       }),
     });
@@ -219,7 +219,7 @@ describe('no-invalid-media-type-examples', () => {
       config: await makeConfig({
         'no-invalid-media-type-examples': {
           severity: 'error',
-          disallowAdditionalProperties: true,
+          allowAdditionalProperties: false,
         },
       }),
     });