Repository: Redocly/redocly-cli
Pull request #815 (2022-08-19 09:35:05)

feat: add rule for validation field names inside components
## What/Why/How?

Add rule for validation field names inside components

## Reference

Closes: https://github.com/Redocly/redocly-cli/issues/757

## Testing

## Screenshots (optional)
<img width="810" alt="Знімок екрана 2022-08-31 о 12 48 52" src="https://user-images.githubusercontent.com/106662428/187651222-f0cb4a7e-5b2f-496f-adec-873057ff4d2d.png">


## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [x] 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 

❌ An unexpected error occurred. For more details, [check console](https://github.com/Redocly/redocly-cli/actions/runs/2888494612) 
```
Error: The process '/usr/local/bin/npm' failed with exit code 1
```

| <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.66%     |    2892/4212    |
|                                                                           🟡                                                                           | Branches   | 61.85%     |    1589/2569    |
|                                                                           🔴                                                                           | Functions  | 59.97%     |     463/772     |
|                                                                           🟡                                                                           | Lines      | 68.48%     |    2679/3912    |

<details><summary>Show new covered files 🐣</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/oas3/spec-example-field-name.ts">`...` / spec-example-field-name.ts</div> | 33.33%     | 0%       | 0%        | 20%   |

</details>


## Test suite run failed
Failed tests: 0/463. Failed suites: 0/77.

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

IgorKarpiuk: @RomanHotsiy @adamaltman Please correct me with the name of the rule if needed

Patch:
diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts
index 9a2b189e..3db26905 100644
--- a/packages/core/src/config/minimal.ts
+++ b/packages/core/src/config/minimal.ts
@@ -45,6 +45,7 @@ export default {
     'no-unused-components': 'warn',
     'no-undefined-server-variable': 'warn',
     'no-server-variables-empty-enum': 'error',
+    'spec-components-invalid-map-name': 'warn',
   },
   oas3_1Rules: {
     'no-invalid-media-type-examples': 'warn',
@@ -55,5 +56,6 @@ export default {
     'no-unused-components': 'warn',
     'no-undefined-server-variable': 'warn',
     'no-server-variables-empty-enum': 'error',
+    'spec-components-invalid-map-name': 'warn',
   },
 } as PluginStyleguideConfig;
diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts
index d345159d..8d3304a9 100644
--- a/packages/core/src/config/recommended.ts
+++ b/packages/core/src/config/recommended.ts
@@ -45,6 +45,7 @@ export default {
     'no-unused-components': 'warn',
     'no-undefined-server-variable': 'error',
     'no-server-variables-empty-enum': 'error',
+    'spec-components-invalid-map-name': 'error',
   },
   oas3_1Rules: {
     'no-invalid-media-type-examples': 'warn',
@@ -55,5 +56,6 @@ export default {
     'no-unused-components': 'warn',
     'no-undefined-server-variable': 'error',
     'no-server-variables-empty-enum': 'error',
+    'spec-components-invalid-map-name': 'error',
   },
 } as PluginStyleguideConfig;
diff --git a/packages/core/src/rules/oas3/index.ts b/packages/core/src/rules/oas3/index.ts
index ffe95ca6..67da09ae 100644
--- a/packages/core/src/rules/oas3/index.ts
+++ b/packages/core/src/rules/oas3/index.ts
@@ -48,6 +48,7 @@ import { NoInvalidParameterExamples } from '../common/no-invalid-parameter-examp
 import { ResponseContainsHeader } from '../common/response-contains-header';
 import { ResponseContainsProperty } from './response-contains-property';
 import { ScalarPropertyMissingExample } from '../common/scalar-property-missing-example';
+import { SpecComponentsInvalidMapName } from './spec-components-invalid-map-name';
 import { Operation4xxProblemDetailsRfc7807 } from './operation-4xx-problem-details-rfc7807';
 
 export const rules = {
@@ -102,6 +103,7 @@ export const rules = {
   'response-contains-header': ResponseContainsHeader,
   'response-contains-property': ResponseContainsProperty,
   'scalar-property-missing-example': ScalarPropertyMissingExample,
+  'spec-components-invalid-map-name': SpecComponentsInvalidMapName,
 } as Oas3RuleSet;
 
 export const preprocessors = {};
diff --git a/packages/core/src/rules/oas3/spec-components-invalid-map-name.ts b/packages/core/src/rules/oas3/spec-components-invalid-map-name.ts
new file mode 100644
index 00000000..5387049c
--- /dev/null
+++ b/packages/core/src/rules/oas3/spec-components-invalid-map-name.ts
@@ -0,0 +1,53 @@
+import { Oas3Rule } from '../../visitors';
+import { Problem, UserContext } from '../../walk';
+import { Location } from '../../ref-utils';
+
+export const SpecComponentsInvalidMapName: Oas3Rule = () => {
+  const KEYS_REGEX = '^[a-zA-Z0-9\\.\\-_]+$';
+
+  function validateKey(
+    key: string | number,
+    report: (problem: Problem) => void,
+    location: Location,
+    component: string
+  ) {
+    if (!new RegExp(KEYS_REGEX).test(key as string)) {
+      report({
+        message: `The map key in ${component} "${key}" does not match the regular expression "${KEYS_REGEX}"`,
+        location: location.key(),
+      });
+    }
+  }
+
+  return {
+    Components: {
+      Parameter(_node, { key, report, location }: UserContext) {
+        validateKey(key, report, location, 'parameters');
+      },
+      Response(_node, { key, report, location }: UserContext) {
+        validateKey(key, report, location, 'responses');
+      },
+      Schema(_node, { key, report, location }: UserContext) {
+        validateKey(key, report, location, 'schemas');
+      },
+      Example(_node, { key, report, location }: UserContext) {
+        validateKey(key, report, location, 'examples');
+      },
+      RequestBody(_node, { key, report, location }: UserContext) {
+        validateKey(key, report, location, 'requestBodies');
+      },
+      Header(_node, { key, report, location }: UserContext) {
+        validateKey(key, report, location, 'headers');
+      },
+      SecurityScheme(_node, { key, report, location }: UserContext) {
+        validateKey(key, report, location, 'securitySchemas');
+      },
+      Link(_node, { key, report, location }: UserContext) {
+        validateKey(key, report, location, 'links');
+      },
+      Callback(_node, { key, report, location }: UserContext) {
+        validateKey(key, report, location, 'callbacks');
+      },
+    },
+  };
+};
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index bbd790b2..f5b216c2 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -52,6 +52,7 @@ const builtInRulesList = [
   'response-contains-header',
   'response-contains-property',
   'scalar-property-missing-example',
+  'spec-components-invalid-map-name',
 ];
 const nodeTypesList = [
   'DefinitionRoot',


Test patch:
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 a4361c16..4e834427 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
@@ -18,6 +18,7 @@ Object {
     "no-server-variables-empty-enum": "error",
     "no-undefined-server-variable": "error",
     "no-unused-components": "warn",
+    "spec-components-invalid-map-name": "error",
   },
   "oas3_1Decorators": Object {},
   "oas3_1Preprocessors": Object {},
@@ -30,6 +31,7 @@ Object {
     "no-server-variables-empty-enum": "error",
     "no-undefined-server-variable": "error",
     "no-unused-components": "warn",
+    "spec-components-invalid-map-name": "error",
   },
   "preprocessors": Object {},
   "recommendedFallback": false,
@@ -89,6 +91,7 @@ Object {
     "no-server-variables-empty-enum": "error",
     "no-undefined-server-variable": "error",
     "no-unused-components": "warn",
+    "spec-components-invalid-map-name": "error",
   },
   "oas3_1Decorators": Object {},
   "oas3_1Preprocessors": Object {},
@@ -101,6 +104,7 @@ Object {
     "no-server-variables-empty-enum": "error",
     "no-undefined-server-variable": "error",
     "no-unused-components": "warn",
+    "spec-components-invalid-map-name": "error",
   },
   "preprocessors": Object {},
   "recommendedFallback": undefined,
diff --git a/packages/core/src/rules/oas3/__tests__/spec-components-invalid-map-name.test.ts b/packages/core/src/rules/oas3/__tests__/spec-components-invalid-map-name.test.ts
new file mode 100644
index 00000000..eb113d66
--- /dev/null
+++ b/packages/core/src/rules/oas3/__tests__/spec-components-invalid-map-name.test.ts
@@ -0,0 +1,217 @@
+import { makeConfig, parseYamlToDocument } from '../../../../__tests__/utils';
+import { outdent } from 'outdent';
+import { lintDocument } from '../../../lint';
+import { BaseResolver } from '../../../resolve';
+
+describe('Oas3 spec-components-invalid-map-name', () => {
+  it('should report about invalid keys inside components', async () => {
+    const document = parseYamlToDocument(outdent`
+      openapi: 3.0.0
+      info:
+        version: 3.0.0
+      components:
+        parameters:
+          my Param:
+            name: param
+            description: param
+            in: path
+        examples:
+          invalid identifier:
+            description: 'Some description'
+            value: 21      
+        responses: 
+          400 status:
+            description: bad request   
+        schemas: 
+          first schema:
+            type: integer
+            format: int64     
+		`);
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'spec-components-invalid-map-name': 'error',
+      }),
+    });
+
+    expect(results).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/components/parameters/my Param",
+              "reportOnKey": true,
+              "source": Source {
+                "absoluteRef": "",
+                "body": "openapi: 3.0.0
+      info:
+        version: 3.0.0
+      components:
+        parameters:
+          my Param:
+            name: param
+            description: param
+            in: path
+        examples:
+          invalid identifier:
+            description: 'Some description'
+            value: 21      
+        responses: 
+          400 status:
+            description: bad request   
+        schemas: 
+          first schema:
+            type: integer
+            format: int64     ",
+                "mimeType": undefined,
+              },
+            },
+          ],
+          "message": "The map key in parameters \\"my Param\\" does not match the regular expression \\"^[a-zA-Z0-9\\\\.\\\\-_]+$\\"",
+          "ruleId": "spec-components-invalid-map-name",
+          "severity": "error",
+          "suggest": Array [],
+        },
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/components/schemas/first schema",
+              "reportOnKey": true,
+              "source": Source {
+                "absoluteRef": "",
+                "body": "openapi: 3.0.0
+      info:
+        version: 3.0.0
+      components:
+        parameters:
+          my Param:
+            name: param
+            description: param
+            in: path
+        examples:
+          invalid identifier:
+            description: 'Some description'
+            value: 21      
+        responses: 
+          400 status:
+            description: bad request   
+        schemas: 
+          first schema:
+            type: integer
+            format: int64     ",
+                "mimeType": undefined,
+              },
+            },
+          ],
+          "message": "The map key in schemas \\"first schema\\" does not match the regular expression \\"^[a-zA-Z0-9\\\\.\\\\-_]+$\\"",
+          "ruleId": "spec-components-invalid-map-name",
+          "severity": "error",
+          "suggest": Array [],
+        },
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/components/responses/400 status",
+              "reportOnKey": true,
+              "source": Source {
+                "absoluteRef": "",
+                "body": "openapi: 3.0.0
+      info:
+        version: 3.0.0
+      components:
+        parameters:
+          my Param:
+            name: param
+            description: param
+            in: path
+        examples:
+          invalid identifier:
+            description: 'Some description'
+            value: 21      
+        responses: 
+          400 status:
+            description: bad request   
+        schemas: 
+          first schema:
+            type: integer
+            format: int64     ",
+                "mimeType": undefined,
+              },
+            },
+          ],
+          "message": "The map key in responses \\"400 status\\" does not match the regular expression \\"^[a-zA-Z0-9\\\\.\\\\-_]+$\\"",
+          "ruleId": "spec-components-invalid-map-name",
+          "severity": "error",
+          "suggest": Array [],
+        },
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/components/examples/invalid identifier",
+              "reportOnKey": true,
+              "source": Source {
+                "absoluteRef": "",
+                "body": "openapi: 3.0.0
+      info:
+        version: 3.0.0
+      components:
+        parameters:
+          my Param:
+            name: param
+            description: param
+            in: path
+        examples:
+          invalid identifier:
+            description: 'Some description'
+            value: 21      
+        responses: 
+          400 status:
+            description: bad request   
+        schemas: 
+          first schema:
+            type: integer
+            format: int64     ",
+                "mimeType": undefined,
+              },
+            },
+          ],
+          "message": "The map key in examples \\"invalid identifier\\" does not match the regular expression \\"^[a-zA-Z0-9\\\\.\\\\-_]+$\\"",
+          "ruleId": "spec-components-invalid-map-name",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+  });
+
+  it('should not report a key of the example does not match the regular expression', async () => {
+    const document = parseYamlToDocument(outdent`
+      openapi: 3.0.0
+      info:
+        version: 3.0.0
+      paths:
+        /store/subscribe:
+          post:
+            parameters:
+              - name: petId
+                in: path
+                schema:
+                  type: integer
+                  format: int64
+                examples:
+                  valid-identifier-1.key:
+                      description: 'Some description'
+                      value: 21
+		`);
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'spec-components-invalid-map-name': 'error',
+      }),
+    });
+
+    expect(results).toMatchInlineSnapshot(`Array []`);
+  });
+});