Repository: Redocly/redocly-cli
Pull request #1412 (2024-01-28 16:40:21)

fix: introduce new built-in rule for missing required schema properties
## What/Why/How?
Introducing new custom rule for missing required schema properties. This performs additional check during linting process to check if some of the required properties are missing.

## Reference
Fixes https://github.com/Redocly/redocly-cli/issues/1333
Fixes https://github.com/Redocly/redocly-cli/issues/477

## Testing
All tests suites were ran and checked locally. Added new tests for newly added rule. Updated snapshots where needed. All tests passed.

## Screenshots (optional)
![image](https://github.com/Redocly/redocly-cli/assets/51505454/1ab37b18-39bd-4876-9dd3-d72081987d00)

## Has code been changed?
Yes

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows (internal)
- [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


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 4357a6c6da1242d8718e770a2d9782c8c3a3a10d

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 2 packages</summary>

  | Name                  | Type  |
| --------------------- | ----- |
| @redocly/openapi-core | Minor |
| @redocly/cli          | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/Redocly/redocly-cli/new/fix/custom-rule-missing-required-schema-properties?filename=.changeset/lucky-flies-shave.md&value=---%0A%22%40redocly%2Fopenapi-core%22%3A%20patch%0A---%0A%0Afix%3A%20introduce%20new%20custom%20rule%20for%20missing%20required%20schema%20properties%0A)


github-actions[bot]: | Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.049 ± 0.019 | 1.027 | 1.079 | 1.01 ± 0.02 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.042 ± 0.017 | 1.023 | 1.076 | 1.00 |

<!-- thollander/actions-comment-pull-request "hyperfine-comparison" -->
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/7687088146) 
```
Error: The process '/opt/hostedtoolcache/node/18.19.0/x64/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 | 76.23%     |    4317/5663    |
|                                                                           🟡                                                                           | Branches   | 65.81%     |    2244/3410    |
|                                                                           🟡                                                                           | Functions  | 68.9%      |     698/1013    |
|                                                                           🟡                                                                           | Lines      | 76.44%     |    4059/5310    |

<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/common/missing-required-schema-properties.ts">`...` / missing-required-schema-properties.ts</div> | 100%       | 80%      | 100%      | 100%  |

</details>


## Test suite run failed

<details><summary>Failed tests: 2/703. Failed suites: 1/102.</summary>
<br/>

```
  ● resolveStyleguideConfig › should resolve extends with local file config which contains path to nested config

    expect(received).toMatchSnapshot()

    Snapshot name: `resolveStyleguideConfig should resolve extends with local file config which contains path to nested config 1`

    - Snapshot  - 1
    + Received  + 1

    @@ -79,11 +79,11 @@
          "boolean-parameter-prefixes": "error",
          "info-contact": "off",
          "info-license": "warn",
          "info-license-url": "warn",
          "local/operation-id-not-test": "error",
    -     "missing-required-schema-properties": "error",
    +     "missing-required-schema-properties": "off",
          "no-ambiguous-paths": "warn",
          "no-enum-type-mismatch": "error",
          "no-http-verbs-in-paths": "off",
          "no-identical-paths": "error",
          "no-invalid-media-type-examples": "warn",

      132 |     delete styleguide.extendPaths;
      133 |     delete styleguide.pluginPaths;
    > 134 |     expect(styleguide).toMatchSnapshot();
          |                        ^
      135 |   });
      136 |
      137 |   it('should resolve custom assertion from plugin', async () => {

      at packages/core/src/config/__tests__/config-resolvers.test.ts:134:24
      at fulfilled (packages/core/src/config/__tests__/config-resolvers.test.ts:5:58)

  ● resolveConfig › should ignore minimal from the root and read local file

    expect(received).toMatchSnapshot()

    Snapshot name: `resolveConfig should ignore minimal from the root and read local file 1`

    - Snapshot  - 1
    + Received  + 1

    @@ -61,11 +61,11 @@
          "boolean-parameter-prefixes": "error",
          "info-contact": "off",
          "info-license": "warn",
          "info-license-url": "warn",
          "local/operation-id-not-test": "error",
    -     "missing-required-schema-properties": "error",
    +     "missing-required-schema-properties": "off",
          "no-ambiguous-paths": "warn",
          "no-enum-type-mismatch": "error",
          "no-http-verbs-in-paths": "off",
          "no-identical-paths": "error",
          "no-invalid-media-type-examples": "error",

      491 |     delete apis['petstore'].styleguide.extendPaths;
      492 |     delete apis['petstore'].styleguide.pluginPaths;
    > 493 |     expect(apis['petstore'].styleguide).toMatchSnapshot();
          |                                         ^
      494 |   });
      495 |
      496 |   it('should default to the extends from the main config if no extends defined', async () => {

      at packages/core/src/config/__tests__/config-resolvers.test.ts:493:41
      at fulfilled (packages/core/src/config/__tests__/config-resolvers.test.ts:5:58)

```

</details>


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

malis42: > Left a few comments.

Fixed all! @tatomyr 
malis42: @adamaltman After consulting with @tatomyr I've moved docs related to code changes from this PR to a new PR here: https://github.com/Redocly/redocly-cli/pull/1417
malis42: @tatomyr Thanks for the suggestions and helping me out understand this edge case. 

Based on our discussion I've pushed changes to resolve basic nesting of `allOf` keyword, as well as resolving nested `$ref` schemas. This however means, that in future we still have to circle back and look at possible resolutions for the `anyOf` and `oneOf` keywords, as well as more complicated schema examples, with multiple nestings etc.
malis42: In the latest commit we've managed to cover `anyOf` keyword schema nesting. Also added two more test cases to check `anyOf` nesting. 

We've also linted [the Rebilly description](https://github.com/Rebilly/api-definitions/blob/main/openapi/openapi.yaml) yaml file with the `no-required-schema-properties-undefined: error` config, and we got 14 errors, which you can see below:
<details>
  <summary>Rebilly docs errors</summary>

  ```bash
 validating openapi/openapi.yaml...
[1] openapi/components/schemas/AdjustReadyToPayoutPaymentMethod.yaml:4:5 at #/required/1

Required property 'feature' is undefined.

2 | required:
3 |   - paymentMethod
4 |   - feature
5 | discriminator:
6 |   propertyName: paymentMethod

Error was generated by the no-required-schema-properties-undefined rule.


[2] openapi/components/schemas/RuleActionUpdateIntuitQuickbooksInvoice.yaml:72:9 at #/allOf/1/required/1

Required property 'customerDisplayName' is undefined.

70 | required:
71 |   - credentialHash
72 |   - customerDisplayName
73 |   - unearnedRevenueAccount
74 |

Error was generated by the no-required-schema-properties-undefined rule.


[3] openapi/components/schemas/RuleActionVoidIntuitQuickbooksInvoice.yaml:11:9 at #/allOf/1/required/1

Required property 'customerDisplayName' is undefined.

 9 | required:
10 |   - credentialHash
11 |   - customerDisplayName
12 |   - unearnedRevenueAccount
13 |

Error was generated by the no-required-schema-properties-undefined rule.


[4] openapi/components/schemas/RuleActionVoidIntuitQuickbooksInvoice.yaml:12:9 at #/allOf/1/required/2

Required property 'unearnedRevenueAccount' is undefined.

10 | - credentialHash
11 | - customerDisplayName
12 | - unearnedRevenueAccount
13 |

Error was generated by the no-required-schema-properties-undefined rule.


[5] openapi/components/schemas/GatewayAccountConfig/Cashflows.yaml:21:13 at #/allOf/1/properties/credentials/required/1

Required property 'authId' is undefined.

19 | required:
20 |   - authPassword
21 |   - authId
22 |

Error was generated by the no-required-schema-properties-undefined rule.


[6] openapi/components/schemas/GatewayAccountConfig/Gooney.yaml:29:13 at #/allOf/1/properties/credentials/required/0

Required property 'username' is undefined.

27 |     writeOnly: true
28 | required:
29 |   - username
30 |   - password
31 |   - apiKey

Error was generated by the no-required-schema-properties-undefined rule.


[7] openapi/components/schemas/GatewayAccountConfig/Gooney.yaml:30:13 at #/allOf/1/properties/credentials/required/1

Required property 'password' is undefined.

28 | required:
29 |   - username
30 |   - password
31 |   - apiKey
32 |   - apiSecret

Error was generated by the no-required-schema-properties-undefined rule.


[8] openapi/components/schemas/GatewayAccountConfig/PayClub.yaml:24:13 at #/allOf/1/properties/credentials/required/0

Required property 'apiKey' is undefined.

22 |     writeOnly: true
23 | required:
24 |   - apiKey
25 |   - accountNumber
26 |   - signKey

Error was generated by the no-required-schema-properties-undefined rule.


[9] openapi/components/schemas/GatewayAccountConfig/Trustly.yaml:31:13 at #/allOf/1/properties/credentials/required/0

Required property 'usernmae' is undefined.

29 |     writeOnly: true
30 | required:
31 |   - usernmae
32 |   - password
33 |   - publicKey

Error was generated by the no-required-schema-properties-undefined rule.


[10] openapi/components/requestBodies/PostOrganization.yaml:7:15 at #/content/application~1json/schema/allOf/1/required/0

Required property 'name' is undefined.

5 | - $ref: ../schemas/Organization.yaml
6 | - required:
7 |     - name
8 |     - website
9 |     - country

Error was generated by the no-required-schema-properties-undefined rule.


[11] openapi/components/requestBodies/PostOrganization.yaml:8:15 at #/content/application~1json/schema/allOf/1/required/1

Required property 'website' is undefined.

 6 | - required:
 7 |     - name
 8 |     - website
 9 |     - country
10 |     - reportCurrency

Error was generated by the no-required-schema-properties-undefined rule.


[12] openapi/components/requestBodies/PostOrganization.yaml:9:15 at #/content/application~1json/schema/allOf/1/required/2

Required property 'country' is undefined.

 7 |             - name
 8 |             - website
 9 |             - country
10 |             - reportCurrency
11 | description: Create organization request.

Error was generated by the no-required-schema-properties-undefined rule.


[13] openapi/components/requestBodies/PostOrganization.yaml:10:15 at #/content/application~1json/schema/allOf/1/required/3

Required property 'reportCurrency' is undefined.

 8 |             - website
 9 |             - country
10 |             - reportCurrency
11 | description: Create organization request.
12 |

Error was generated by the no-required-schema-properties-undefined rule.


[14] openapi/components/schemas/PaymentGatewayMetadata.yaml:107:9 at #/properties/currencies/required/0

Required property 'mode' is undefined.

105 | description: Currencies metadata.
106 | required:
107 |   - mode
108 | oneOf:
109 |   - title: Unrestricted

Error was generated by the no-required-schema-properties-undefined rule.


openapi/openapi.yaml: validated in 420ms

❌ Validation failed with 14 errors.
run `redocly lint --generate-ignore-file` to add all problems to the ignore file.
  ```
</details>

From the 14 errors, we can get rid of 4 of them by introducing small change to the `openapi/components/requestBodies/PostOrganization` file:

<details>
<summary>Schema changes inside</summary>

**Before:**
```
content:
  application/json:
    schema:
      allOf:
        - $ref: ../schemas/Organization.yaml
        - required:
            - name
            - website
            - country
            - reportCurrency
description: Create organization request.
```

**After:**
```
content:
  application/json:
    schema:
      allOf:
        - $ref: ../schemas/Organization.yaml
      required:
        - name
        - website
        - country
        - reportCurrency
description: Create organization request.
```
</details>

From the remaining 10 errors, there is only 1 that is currently not covered by our rule implementation, related to the `oneOf` keyword in the `openapi/components/schemas/PaymentGatewayMetadata.yaml` file. There is however a workaround for this error, that can be achieved by introducing small changes in schema:

<details>
<summary>More schema changes inside</summary>
Line 103 in `openapi/components/schemas/PaymentGatewayMetadata.yaml`

**Before:**

```
currencies:
    type: object
    description: Currencies metadata.
    required:
      - mode
    oneOf:
      - title: Unrestricted
        properties:
          mode:
            description: Specifies how the currencies are compared.
            type: string
            enum:
              - unknown
              - all
      - title: Subset
        required:
          - values
        properties:
          mode:
            description: Specifies how the currencies are compared.
            type: string
            enum:
              - subset
          values:
            description: List of supported currencies.
            type: array
            minItems: 1
            uniqueItems: true
            items:
              $ref: ./CurrencyCode.yaml
```

**After:**
```
currencies:
    type: object
    description: Currencies metadata.
    oneOf:
      - title: Unrestricted
        properties:
          mode:
            description: Specifies how the currencies are compared.
            type: string
            enum:
              - unknown
              - all
        required:
          - mode
      - title: Subset
        required:
          - values
          - mode
        properties:
          mode:
            description: Specifies how the currencies are compared.
            type: string
            enum:
              - subset
          values:
            description: List of supported currencies.
            type: array
            minItems: 1
            uniqueItems: true
            items:
              $ref: ./CurrencyCode.yaml
```
</details>

Remaining 9 errors are valid errors related to some required schema properties that are undefined. Probably something to check and fix. Some of them are typos, some are related to incorrect property name, some errors are there cause the required property is missing. 

CC: @adamaltman @RomanHotsiy @tatomyr 

Patch:
diff --git a/.changeset/lemon-onions-approve.md b/.changeset/lemon-onions-approve.md
new file mode 100644
index 00000000..c505ea7b
--- /dev/null
+++ b/.changeset/lemon-onions-approve.md
@@ -0,0 +1,5 @@
+---
+"@redocly/openapi-core": minor
+---
+
+Added new `no-required-schema-properties-undefined` rule to check if each required schema property is defined.
diff --git a/packages/core/src/config/all.ts b/packages/core/src/config/all.ts
index 331a5672..33593373 100644
--- a/packages/core/src/config/all.ts
+++ b/packages/core/src/config/all.ts
@@ -43,6 +43,7 @@ const all: PluginStyleguideConfig<'built-in'> = {
     'path-params-defined': 'error',
     'required-string-property-missing-min-length': 'error',
     'response-contains-header': 'error',
+    'no-required-schema-properties-undefined': 'error',
   },
   oas2Rules: {
     'boolean-parameter-prefixes': 'error',
diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts
index 3361ae12..950dde9e 100644
--- a/packages/core/src/config/minimal.ts
+++ b/packages/core/src/config/minimal.ts
@@ -40,6 +40,7 @@ const minimal: PluginStyleguideConfig<'built-in'> = {
     'response-contains-header': 'off',
     'path-segment-plural': 'off',
     'scalar-property-missing-example': 'off',
+    'no-required-schema-properties-undefined': 'off',
   },
   oas2Rules: {
     'boolean-parameter-prefixes': 'off',
diff --git a/packages/core/src/config/recommended-strict.ts b/packages/core/src/config/recommended-strict.ts
index deceed8f..aee887be 100644
--- a/packages/core/src/config/recommended-strict.ts
+++ b/packages/core/src/config/recommended-strict.ts
@@ -40,6 +40,7 @@ const recommendedStrict: PluginStyleguideConfig<'built-in'> = {
     'required-string-property-missing-min-length': 'off',
     'response-contains-header': 'off',
     'scalar-property-missing-example': 'off',
+    'no-required-schema-properties-undefined': 'off',
   },
   oas2Rules: {
     'boolean-parameter-prefixes': 'off',
diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts
index 2855bcda..f24c39b5 100644
--- a/packages/core/src/config/recommended.ts
+++ b/packages/core/src/config/recommended.ts
@@ -40,6 +40,7 @@ const recommended: PluginStyleguideConfig<'built-in'> = {
     'required-string-property-missing-min-length': 'off',
     'response-contains-header': 'off',
     'scalar-property-missing-example': 'off',
+    'no-required-schema-properties-undefined': 'off',
   },
   oas2Rules: {
     'boolean-parameter-prefixes': 'off',
diff --git a/packages/core/src/rules/common/no-required-schema-properties-undefined.ts b/packages/core/src/rules/common/no-required-schema-properties-undefined.ts
new file mode 100644
index 00000000..ef26c126
--- /dev/null
+++ b/packages/core/src/rules/common/no-required-schema-properties-undefined.ts
@@ -0,0 +1,53 @@
+import { Oas2Rule, Oas3Rule } from '../../visitors';
+import { Oas3Schema, Oas3_1Schema } from '../../typings/openapi';
+import { Oas2Schema } from 'core/src/typings/swagger';
+import { UserContext } from 'core/src/walk';
+import { isRef } from '../../ref-utils';
+
+export const NoRequiredSchemaPropertiesUndefined: Oas3Rule | Oas2Rule = () => {
+  return {
+    Schema: {
+      enter(
+        schema: Oas3Schema | Oas3_1Schema | Oas2Schema,
+        { location, report, resolve }: UserContext
+      ) {
+        if (!schema.required) return;
+        const visitedSchemas: Set<Oas3Schema | Oas3_1Schema | Oas2Schema> = new Set();
+
+        const elevateProperties = (
+          schema: Oas3Schema | Oas3_1Schema | Oas2Schema
+        ): Record<string, Oas3Schema | Oas3_1Schema | Oas2Schema> => {
+          // Check if the schema has been visited before processing it
+          if (visitedSchemas.has(schema)) {
+            return {};
+          }
+          visitedSchemas.add(schema);
+
+          if (isRef(schema)) {
+            return elevateProperties(
+              resolve(schema).node as Oas3Schema | Oas3_1Schema | Oas2Schema
+            );
+          }
+
+          return Object.assign(
+            {},
+            schema.properties,
+            ...(schema.allOf?.map(elevateProperties) ?? []),
+            ...((schema as Oas3Schema).anyOf?.map(elevateProperties) ?? [])
+          );
+        };
+
+        const allProperties = elevateProperties(schema);
+
+        for (const [i, requiredProperty] of schema.required.entries()) {
+          if (!allProperties || allProperties[requiredProperty] === undefined) {
+            report({
+              message: `Required property '${requiredProperty}' is undefined.`,
+              location: location.child(['required', i]),
+            });
+          }
+        }
+      },
+    },
+  };
+};
diff --git a/packages/core/src/rules/oas2/index.ts b/packages/core/src/rules/oas2/index.ts
index e97527e1..63588484 100644
--- a/packages/core/src/rules/oas2/index.ts
+++ b/packages/core/src/rules/oas2/index.ts
@@ -41,6 +41,7 @@ import { ResponseContainsProperty } from './response-contains-property';
 import { ScalarPropertyMissingExample } from '../common/scalar-property-missing-example';
 import { RequiredStringPropertyMissingMinLength } from '../common/required-string-property-missing-min-length';
 import { SpecStrictRefs } from '../common/spec-strict-refs';
+import { NoRequiredSchemaPropertiesUndefined } from '../common/no-required-schema-properties-undefined';
 
 import type { Oas2RuleSet } from 'core/src/oas-types';
 
@@ -88,6 +89,7 @@ export const rules: Oas2RuleSet<'built-in'> = {
   'scalar-property-missing-example': ScalarPropertyMissingExample,
   'required-string-property-missing-min-length': RequiredStringPropertyMissingMinLength,
   'spec-strict-refs': SpecStrictRefs,
+  'no-required-schema-properties-undefined': NoRequiredSchemaPropertiesUndefined,
 };
 
 export const preprocessors = {};
diff --git a/packages/core/src/rules/oas3/index.ts b/packages/core/src/rules/oas3/index.ts
index 90028437..02be5610 100644
--- a/packages/core/src/rules/oas3/index.ts
+++ b/packages/core/src/rules/oas3/index.ts
@@ -53,6 +53,7 @@ import { RequiredStringPropertyMissingMinLength } from '../common/required-strin
 import { SpecStrictRefs } from '../common/spec-strict-refs';
 import { ComponentNameUnique } from './component-name-unique';
 import { ArrayParameterSerialization } from './array-parameter-serialization';
+import { NoRequiredSchemaPropertiesUndefined } from '../common/no-required-schema-properties-undefined';
 
 export const rules: Oas3RuleSet<'built-in'> = {
   spec: Spec,
@@ -110,6 +111,7 @@ export const rules: Oas3RuleSet<'built-in'> = {
   'spec-strict-refs': SpecStrictRefs,
   'component-name-unique': ComponentNameUnique,
   'array-parameter-serialization': ArrayParameterSerialization,
+  'no-required-schema-properties-undefined': NoRequiredSchemaPropertiesUndefined,
 };
 
 export const preprocessors = {};
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index 0fcc9ff4..83eac81e 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -48,6 +48,7 @@ const builtInCommonOASRules = [
   'security-defined',
   'spec-strict-refs',
   'no-unresolved-refs',
+  'no-required-schema-properties-undefined',
 ] as const;
 
 export type BuiltInCommonOASRuleId = typeof builtInCommonOASRules[number];


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 a9136e1f..e2b88b23 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
@@ -74,6 +74,7 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
     "no-invalid-parameter-examples": "off",
     "no-invalid-schema-examples": "off",
     "no-path-trailing-slash": "error",
+    "no-required-schema-properties-undefined": "off",
     "no-unresolved-refs": "error",
     "operation-2xx-response": "warn",
     "operation-4xx-response": "error",
@@ -198,6 +199,7 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
     "no-invalid-parameter-examples": "off",
     "no-invalid-schema-examples": "off",
     "no-path-trailing-slash": "error",
+    "no-required-schema-properties-undefined": "off",
     "no-unresolved-refs": "error",
     "operation-2xx-response": "error",
     "operation-4xx-response": "off",
diff --git a/packages/core/src/rules/common/__tests__/no-required-schema-properties-undefined.test.ts b/packages/core/src/rules/common/__tests__/no-required-schema-properties-undefined.test.ts
new file mode 100644
index 00000000..eedc4dc6
--- /dev/null
+++ b/packages/core/src/rules/common/__tests__/no-required-schema-properties-undefined.test.ts
@@ -0,0 +1,550 @@
+import { outdent } from 'outdent';
+import { lintDocument } from '../../../lint';
+import { parseYamlToDocument, replaceSourceWithRef, makeConfig } from '../../../../__tests__/utils';
+import { BaseResolver } from '../../../resolve';
+
+describe('no-required-schema-properties-undefined', () => {
+  it('should report if one or more of the required properties are undefined', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Pet:
+                type: object
+                required:
+                  - name
+                  - id
+                  - test
+                properties:
+                  id:
+                    type: integer
+                    format: int64
+                  name:
+                    type: string
+                    example: doggie
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/components/schemas/Pet/required/2",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Required property 'test' is undefined.",
+          "ruleId": "no-required-schema-properties-undefined",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report if one or more of the required properties are undefined, including allOf nested schemas', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Pet:
+                type: object
+                allOf:
+                  - properties:
+                      foo:
+                        type: string
+                required:
+                  - id
+                  - foo
+                  - test
+                properties:
+                  id:
+                    type: integer
+                    format: int64
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/components/schemas/Pet/required/2",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Required property 'test' is undefined.",
+          "ruleId": "no-required-schema-properties-undefined",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report if one or more of the required properties are undefined when used in schema with allOf keyword', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Cat:
+                description: A representation of a cat
+                allOf:
+                  - $ref: '#/components/schemas/Pet'
+                  - type: object
+                    properties:
+                      huntingSkill:
+                        type: string
+                    required:
+                      - huntingSkill
+                      - name
+              Pet:
+                type: object
+                required:
+                  - photoUrls
+                properties:
+                  name:
+                    type: string
+                  photoUrls:
+                    type: string
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/components/schemas/Cat/allOf/1/required/1",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Required property 'name' is undefined.",
+          "ruleId": "no-required-schema-properties-undefined",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('should not report required properties are present after resolving $refs when used in schema with allOf keyword', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Cat:
+                description: A representation of a cat
+                allOf:
+                  - $ref: '#/components/schemas/Pet'
+                  - type: object
+                    properties:
+                      huntingSkill:
+                        type: string
+                    required:
+                      - huntingSkill
+                required:
+                  - name
+              Pet:
+                type: object
+                required:
+                  - photoUrls
+                properties:
+                  name:
+                    type: string
+                  photoUrls:
+                    type: string
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
+  });
+
+  it('should report with few messages if more than one of the required properties are undefined', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Pet:
+                type: object
+                required:
+                  - name
+                  - id
+                  - test
+                  - test2
+                properties:
+                  id:
+                    type: integer
+                    format: int64
+                  name:
+                    type: string
+                    example: doggie
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/components/schemas/Pet/required/2",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Required property 'test' is undefined.",
+          "ruleId": "no-required-schema-properties-undefined",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "location": [
+            {
+              "pointer": "#/components/schemas/Pet/required/3",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Required property 'test2' is undefined.",
+          "ruleId": "no-required-schema-properties-undefined",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('should not report if all of the required properties are present', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Pet:
+                type: object
+                required:
+                  - name
+                  - id
+                properties:
+                  id:
+                    type: integer
+                    format: int64
+                  name:
+                    type: string
+                    example: doggie
+                  test:
+                    type: string
+                    example: test
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
+  });
+
+  it('should not cause stack overflow when there are circular references in schemas', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Cat:
+                description: A representation of a cat
+                allOf:
+                  - $ref: '#/components/schemas/Pet'
+                  - type: object
+                    properties:
+                      huntingSkill:
+                        type: string
+                    required:
+                      - huntingSkill
+                required:
+                  - name
+              Pet:
+                description: A schema of pet
+                allOf:
+                  - $ref: '#/components/schemas/Cat'
+                  - type: object
+                    required:
+                      - photoUrls
+                    properties:
+                      name:
+                        type: string
+                      photoUrls:
+                        type: string
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
+  });
+
+  it('should report if there is any required schema that is undefined, including checking nested allOf schemas', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Cat:
+                description: A representation of a cat
+                allOf:
+                  - $ref: '#/components/schemas/Pet'
+                  - type: object
+                    properties:
+                      huntingSkill:
+                        type: string
+                    required:
+                      - huntingSkill
+                required:
+                  - test
+                  - id
+              Pet:
+                description: A schema of pet
+                allOf:
+                  - type: object
+                    required:
+                      - photoUrls
+                    properties:
+                      photoUrls:
+                        type: string
+                  - type: object
+                    required:
+                      - name
+                    properties:
+                      name:
+                        type: string
+                      id:
+                        type: string
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/components/schemas/Cat/required/0",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Required property 'test' is undefined.",
+          "ruleId": "no-required-schema-properties-undefined",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('should not report if required property is present in one of the nested reference schemas', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Animal:
+                type: object
+                properties:
+                  commonProperty:
+                    type: string
+              Mammal:
+                allOf:
+                  - $ref: '#/components/schemas/Animal'
+                  - type: object
+                    properties:
+                      furColor:
+                        type: string
+                    required:
+                      - furColor
+              Carnivore:
+                allOf:
+                  - $ref: '#/components/schemas/Mammal'
+                  - type: object
+                    properties:
+                      diet:
+                        type: string
+                    required:
+                      - diet
+              Tiger:
+                allOf:
+                  - $ref: '#/components/schemas/Carnivore'
+                  - type: object
+                    properties:
+                      stripes:
+                        type: boolean
+                    required:
+                      - stripes
+                required:
+                  - commonProperty
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
+  });
+
+  it('should report if one or more of the required properties are undefined when used in schema with anyOf keyword', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Cat:
+                description: A representation of a cat
+                anyOf:
+                  - $ref: '#/components/schemas/Pet'
+                  - type: object
+                    properties:
+                      huntingSkill:
+                        type: string
+                    required:
+                      - huntingSkill
+                      - name
+              Pet:
+                type: object
+                required:
+                  - photoUrls
+                properties:
+                  name:
+                    type: string
+                  photoUrls:
+                    type: string
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/components/schemas/Cat/anyOf/1/required/1",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Required property 'name' is undefined.",
+          "ruleId": "no-required-schema-properties-undefined",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report if one or more of the required properties are undefined when used in schema with allOf keyword', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              Cat:
+                description: A representation of a cat
+                anyOf:
+                  - $ref: '#/components/schemas/Pet'
+                  - type: object
+                    properties:
+                      huntingSkill:
+                        type: string
+                    required:
+                      - huntingSkill
+                required:
+                  - name
+              Pet:
+                type: object
+                required:
+                  - photoUrls
+                properties:
+                  name:
+                    type: string
+                  photoUrls:
+                    type: string
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'no-required-schema-properties-undefined': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
+  });
+});