Repository: stoplightio/spectral
Pull request #1429 (2020-12-29 23:25:25)

feat(oas): validate 'default' in schemas
Closes https://github.com/stoplightio/spectral/issues/1402

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No


**Additional context**

I'm very torn when it comes to the solution chosen here.

Besides what's implemented here, I considered the following:
- 2 completely new rules called `oas2-valid-schema-default` and `oas3-valid-schema-default` - this might cause redundant fragmentation
- renaming existing rules to something more generic, i.e. `oas2-valid-schema` (wouldn't it be confusing, though?) - this one would be breaking and therefore defer the release of the fix

I still think that ideally we should rename the rules, so that we can validate more things in the future using the same rule.
I'm not fully certain, however, so hm... @philsturgeon ideas?


Patch:
diff --git a/src/rulesets/oas/functions/oasExample.ts b/src/rulesets/oas/functions/oasExample.ts
index 0a05f56f..a4c4fb2d 100644
--- a/src/rulesets/oas/functions/oasExample.ts
+++ b/src/rulesets/oas/functions/oasExample.ts
@@ -1,6 +1,7 @@
 import type { IFunction, IFunctionContext, IFunctionResult, JSONSchema } from '../../../types';
 import type { ISchemaOptions } from '../../../functions/schema';
 import { isObject } from './utils/isObject';
+import type { Dictionary, JsonPath, Optional } from '@stoplight/types';
 
 interface IOasExampleOptions {
   oasVersion: 2 | 3;
@@ -8,70 +9,51 @@ interface IOasExampleOptions {
   type: 'media' | 'schema';
 }
 
-const ORDER_CHECK = {
-  media: {
-    2: [
-      {
-        field: 'examples',
-        multiple: true,
-        keyed: false,
-      },
-    ],
-    3: [
-      {
-        field: 'example',
-        multiple: false,
-        keyed: false,
-      },
-      {
-        field: 'examples',
-        multiple: true,
-        keyed: true,
-      },
-    ],
-  },
-  schema: {
-    2: [
-      {
-        field: 'example',
-        multiple: false,
-        keyed: false,
-      },
-      {
-        field: 'x-example',
-        multiple: false,
-        keyed: false,
-      },
-    ],
-    3: [
-      {
-        field: 'example',
-        multiple: false,
-        keyed: false,
-      },
-    ],
-  },
+type MediaValidationItem = {
+  field: string;
+  multiple: boolean;
+  keyed: boolean;
 };
 
-export const oasExample: IFunction<IOasExampleOptions> = function (
-  this: IFunctionContext,
-  targetVal,
-  opts,
-  paths,
-  otherValues,
-) {
-  if (!isObject(targetVal)) {
-    return;
-  }
+const MEDIA_VALIDATION_ITEMS: Dictionary<MediaValidationItem[], 2 | 3> = {
+  2: [
+    {
+      field: 'examples',
+      multiple: true,
+      keyed: false,
+    },
+  ],
+  3: [
+    {
+      field: 'example',
+      multiple: false,
+      keyed: false,
+    },
+    {
+      field: 'examples',
+      multiple: true,
+      keyed: true,
+    },
+  ],
+};
 
-  const schemaOpts: ISchemaOptions = {
-    schema: opts.schemaField === '$' ? targetVal : (targetVal[opts.schemaField] as JSONSchema),
-    oasVersion: opts.oasVersion,
-  };
+const SCHEMA_VALIDATION_ITEMS: Dictionary<string[], 2 | 3> = {
+  2: ['example', 'x-example', 'default'],
+  3: ['example', 'default'],
+};
 
-  const order = ORDER_CHECK[opts.type][opts.oasVersion];
+type ValidationItem = {
+  value: unknown;
+  path: JsonPath;
+};
 
-  for (const { field, keyed, multiple } of order) {
+function* getMediaValidationItems(
+  items: MediaValidationItem[],
+  targetVal: Dictionary<unknown>,
+  givenPath: JsonPath,
+  oasVersion: 2 | 3,
+): Iterable<ValidationItem> {
+  for (const { field, keyed, multiple } of items) {
     if (!(field in targetVal)) {
       continue;
     }
@@ -79,57 +61,96 @@ export const oasExample: IFunction<IOasExampleOptions> = function (
     const value = targetVal[field];
 
     if (multiple) {
-      if (!isObject(value)) {
-        continue;
-      }
-
-      const results: IFunctionResult[] = [];
+      if (!isObject(value)) continue;
 
       for (const exampleKey of Object.keys(value)) {
         const exampleValue = value[exampleKey];
-        if (opts.oasVersion === 3 && keyed && (!isObject(exampleValue) || 'externalValue' in exampleValue)) {
+        if (oasVersion === 3 && keyed && (!isObject(exampleValue) || 'externalValue' in exampleValue)) {
           // should be covered by oas3-examples-value-or-externalValue
           continue;
         }
 
-        const targetPath = [...paths.given, field, exampleKey];
+        const targetPath = [...givenPath, field, exampleKey];
 
         if (keyed) {
           targetPath.push('value');
         }
 
-        const result = this.functions.schema.call(
-          this,
-          keyed && isObject(exampleValue) ? exampleValue.value : exampleValue,
-          schemaOpts,
-          {
-            given: paths.given,
-            target: targetPath,
-          },
-          otherValues,
-        );
-
-        if (Array.isArray(result)) {
-          results.push(...result);
-        }
+        yield {
+          value: keyed && isObject(exampleValue) ? exampleValue.value : exampleValue,
+          path: targetPath,
+        };
       }
 
-      return results;
+      return;
     } else {
-      return this.functions.schema.call(
-        this,
+      return yield {
         value,
-        schemaOpts,
-        {
-          given: paths.given,
-          target: [...paths.given, field],
-        },
-        otherValues,
-      );
+        path: [...givenPath, field],
+      };
+    }
+  }
+}
+
+function* getSchemaValidationItems(
+  fields: string[],
+  targetVal: Dictionary<unknown>,
+  givenPath: JsonPath,
+): Iterable<ValidationItem> {
+  for (const field of fields) {
+    if (!(field in targetVal)) {
+      continue;
+    }
+
+    yield {
+      value: targetVal[field],
+      path: [...givenPath, field],
+    };
+  }
+}
+
+export const oasExample: IFunction<IOasExampleOptions> = function (
+  this: IFunctionContext,
+  targetVal,
+  opts,
+  paths,
+  otherValues,
+) {
+  if (!isObject(targetVal)) {
+    return;
+  }
+
+  const schemaOpts: ISchemaOptions = {
+    schema: opts.schemaField === '$' ? targetVal : (targetVal[opts.schemaField] as JSONSchema),
+    oasVersion: opts.oasVersion,
+  };
+
+  let results: Optional<IFunctionResult[]> = void 0;
+
+  const validationItems =
+    opts.type === 'schema'
+      ? getSchemaValidationItems(SCHEMA_VALIDATION_ITEMS[opts.oasVersion], targetVal, paths.given)
+      : getMediaValidationItems(MEDIA_VALIDATION_ITEMS[opts.oasVersion], targetVal, paths.given, opts.oasVersion);
+
+  for (const validationItem of validationItems) {
+    const result = this.functions.schema.call(
+      this,
+      validationItem.value,
+      schemaOpts,
+      {
+        given: paths.given,
+        target: validationItem.path,
+      },
+      otherValues,
+    );
+
+    if (Array.isArray(result)) {
+      if (results === void 0) results = [];
+      results.push(...result);
     }
   }
 
-  return;
+  return results;
 };
 
 export default oasExample;
diff --git a/src/rulesets/oas/index.json b/src/rulesets/oas/index.json
index 8ecb8581..166f32ca 100644
--- a/src/rulesets/oas/index.json
+++ b/src/rulesets/oas/index.json
@@ -406,9 +406,9 @@
       "severity": 0,
       "type": "validation",
       "given": [
-        "$..definitions..[?(@property !== 'properties' && (@.example !== void 0 || @['x-example'] !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
-        "$..parameters..[?(@property !== 'properties' && (@.example !== void 0 || @['x-example'] !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
-        "$..responses..[?(@property !== 'properties' && (@.example !== void 0 || @['x-example'] !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]"
+        "$..definitions..[?(@property !== 'properties' && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
+        "$..parameters..[?(@property !== 'properties' && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
+        "$..responses..[?(@property !== 'properties' && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]"
       ],
       "then": {
         "function": "oasExample",
@@ -609,10 +609,10 @@
       "recommended": true,
       "type": "validation",
       "given": [
-        "$.components.schemas..[?(@property !== 'properties' && @.example !== void 0 && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
-        "$..content..[?(@property !== 'properties' && @.example !== void 0 && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
-        "$..headers..[?(@property !== 'properties' && @.example !== void 0 && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
-        "$..parameters..[?(@property !== 'properties' && @.example !== void 0 && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]"
+        "$.components.schemas..[?(@property !== 'properties' && (@.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
+        "$..content..[?(@property !== 'properties' && (@.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
+        "$..headers..[?(@property !== 'properties' && (@.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]",
+        "$..parameters..[?(@property !== 'properties' && (@.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]"
       ],
       "then": {
         "function": "oasExample",


Test patch:
diff --git a/src/rulesets/oas/__tests__/oas2-valid-schema-example.ts b/src/rulesets/oas/__tests__/oas2-valid-schema-example.ts
index 8ed8b1b8..c2e86595 100644
--- a/src/rulesets/oas/__tests__/oas2-valid-schema-example.ts
+++ b/src/rulesets/oas/__tests__/oas2-valid-schema-example.ts
@@ -36,6 +36,59 @@ describe('oas2-valid-schema-example', () => {
       expect(results).toHaveLength(0);
     });
 
+    test('will pass when both examples are valid', async () => {
+      const results = await s.run({
+        [parentField]: [
+          {
+            in: 'body',
+            schema: {
+              type: 'string',
+              example: 'doggie',
+              'x-example': 'doggie',
+            },
+          },
+        ],
+      });
+      expect(results).toHaveLength(0);
+    });
+
+    test('will pass when default value is valid', async () => {
+      const results = await s.run({
+        [parentField]: [
+          {
+            in: 'body',
+            schema: {
+              type: 'string',
+              default: '2',
+            },
+          },
+        ],
+      });
+      expect(results).toHaveLength(0);
+    });
+
+    test('will fail when one of examples is invalid', async () => {
+      const results = await s.run({
+        [parentField]: [
+          {
+            in: 'body',
+            schema: {
+              type: 'string',
+              example: 'doggie',
+              'x-example': 2,
+            },
+          },
+        ],
+      });
+      expect(results).toEqual([
+        expect.objectContaining({
+          code: 'oas2-valid-schema-example',
+          message: '`x-example` property type should be string',
+          severity: DiagnosticSeverity.Error,
+        }),
+      ]);
+    });
+
     test.each(['example', 'x-example'])('will fail when simple %s is invalid', async field => {
       const results = await s.run({
         [parentField]: [
@@ -56,6 +109,27 @@ describe('oas2-valid-schema-example', () => {
       ]);
     });
 
+    test('will fail when default value is invalid', async () => {
+      const results = await s.run({
+        [parentField]: [
+          {
+            in: 'body',
+            schema: {
+              type: 'string',
+              default: 2,
+            },
+          },
+        ],
+      });
+      expect(results).toEqual([
+        expect.objectContaining({
+          code: 'oas2-valid-schema-example',
+          message: '`default` property type should be string',
+          severity: DiagnosticSeverity.Error,
+        }),
+      ]);
+    });
+
     describe.each(['', null, 0, false])('given falsy %s value', value => {
       test.each(['example', 'x-example'])('will validate empty %s value', async field => {
         const results = await s.run({
diff --git a/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts b/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts
index e553588c..ef01ef17 100644
--- a/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts
+++ b/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts
@@ -38,6 +38,20 @@ describe('oas3-valid-schema-example', () => {
       expect(results).toHaveLength(0);
     });
 
+    test('will pass when default value is valid', async () => {
+      const results = await s.run({
+        [field]: {
+          schemas: {
+            xoxo: {
+              type: 'string',
+              example: 'doggie',
+            },
+          },
+        },
+      });
+      expect(results).toHaveLength(0);
+    });
+
     test('will fail when simple example is invalid', async () => {
       const results = await s.run({
         openapi: '3.0.2',
@@ -59,6 +73,26 @@ describe('oas3-valid-schema-example', () => {
       ]);
     });
 
+    test('will fail when default value is invalid', async () => {
+      const results = await s.run({
+        [field]: {
+          schemas: {
+            xoxo: {
+              type: 'string',
+              default: 2,
+            },
+          },
+        },
+      });
+      expect(results).toEqual([
+        expect.objectContaining({
+          code: 'oas3-valid-schema-example',
+          message: '`default` property type should be string',
+          severity: DiagnosticSeverity.Error,
+        }),
+      ]);
+    });
+
     test('will fail for valid parents examples which contain invalid child examples', async () => {
       const results = await s.run({
         openapi: '3.0.2',