Repository: stoplightio/spectral (ts)

Issue:
Create OpenAPI rule for enums that don't match the attribute type
As an example, if I have a numeric attribute in a JSON schema object. I want to ensure that any enumerations of that attribute are of the same numeric type (ie, not a string). It should be flagged as an error when this occurs.

For example, the JSON schema below should trigger an error:

```yaml
  a_model:
    type: object
    properties:
      id:
        type: integer
        description: Unique asset identifier
        enum:
          - 'a string!'
        format: int64
```

Since the `id` field is of type integer but the enum is a string. While this:

```yaml
  a_model:
    type: object
    properties:
      id:
        type: integer
        description: Unique asset identifier
        enum:
          - 123
        format: int64
```

Should not.

Created from https://github.com/stoplightio/studio/issues/116

Pull request description:
feat: new rule to detect enum value that do not respect specified type
Fixes #570

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No


Patch:
diff --git a/docs/reference/functions.md b/docs/reference/functions.md
index 76bfc128..58138297 100644
--- a/docs/reference/functions.md
+++ b/docs/reference/functions.md
@@ -264,3 +264,7 @@ example-value-or-externalValue:
       - externalValue
       - value
 ```
+
+## typedEnum
+
+When both a `type` and `enum` are defined for a property, the enum values must respect the type.
diff --git a/docs/reference/openapi-rules.md b/docs/reference/openapi-rules.md
index efd3d917..388e0e00 100644
--- a/docs/reference/openapi-rules.md
+++ b/docs/reference/openapi-rules.md
@@ -380,6 +380,44 @@ tags:
 
 **Recommended:** No
 
+### typed-enum
+
+Enum values should respect the `type` specifier. 
+
+**Recommended:** Yes
+
+**Good Example**
+
+```yaml
+TheGoodModel:
+  type: object
+  properties:
+    number_of_connectors:
+      type: integer
+      description: The number of extension points.
+      enum:
+        - 1
+        - 2
+        - 4
+        - 8
+```
+
+**Bad Example**
+
+```yaml
+TheBadModel:
+  type: object
+  properties:
+    number_of_connectors:
+      type: integer
+      description: The number of extension points.
+      enum:
+        - 1
+        - 2
+        - 'a string!'
+        - 8
+```
+
 ## OpenAPI v2.0-only
 
 These rules will only apply to OpenAPI v2.0 documents.
diff --git a/src/functions/index.ts b/src/functions/index.ts
index 49c4dde4..79c4e72a 100644
--- a/src/functions/index.ts
+++ b/src/functions/index.ts
@@ -11,4 +11,5 @@ export const functions = {
   undefined: require('./undefined').undefined,
   xor: require('./xor').xor,
   unreferencedReusableObject: require('./unreferencedReusableObject').unreferencedReusableObject,
+  typedEnum: require('./typedEnum').typedEnum,
 };
diff --git a/src/functions/typedEnum.ts b/src/functions/typedEnum.ts
new file mode 100644
index 00000000..4fee0707
--- /dev/null
+++ b/src/functions/typedEnum.ts
@@ -0,0 +1,40 @@
+import { IFunction, IFunctionResult, IRule, RuleFunction } from '../types';
+import { schema } from './schema';
+
+export type TypedEnumRule = IRule<RuleFunction.TYPED_ENUM>;
+
+export const typedEnum: IFunction = (targetVal, opts, paths, otherValues): void | IFunctionResult[] => {
+  const { enum: enumValues, ...initialSchema } = targetVal;
+
+  if (!Array.isArray(enumValues)) {
+    return;
+  }
+
+  const innerSchema = { type: initialSchema.type, enum: initialSchema.enum };
+  const schemaObject = { schema: innerSchema };
+
+  const incorrectValues: Array<{ index: number; val: unknown }> = [];
+
+  enumValues.forEach((val, index) => {
+    const res = schema(val, schemaObject, paths, otherValues);
+
+    if (res !== undefined && res.length !== 0) {
+      incorrectValues.push({ index, val });
+    }
+  });
+
+  if (incorrectValues.length === 0) {
+    return;
+  }
+
+  const { type } = initialSchema;
+
+  const rootPath = paths.target ?? paths.given;
+
+  return incorrectValues.map(bad => {
+    return {
+      message: `Enum value \`${bad.val}\` does not respect the specified type \`${type}\`.`,
+      path: [...rootPath, 'enum', bad.index],
+    };
+  });
+};
diff --git a/src/rulesets/oas/index.json b/src/rulesets/oas/index.json
index 3dd384ed..0b407004 100644
--- a/src/rulesets/oas/index.json
+++ b/src/rulesets/oas/index.json
@@ -416,6 +416,16 @@
         "function": "refSiblings"
       }
     },
+    "typed-enum": {
+      "description": "Enum values should respect the specified type.",
+      "message": "{{error}}",
+      "recommended": true,
+      "type": "validation",
+      "given": "$..[?(@.enum && @.type)]",
+      "then": {
+        "function": "typedEnum"
+      }
+    },
     "oas2-api-host": {
       "description": "OpenAPI `host` must be present and non-empty string.",
       "recommended": true,
diff --git a/src/types/enums.ts b/src/types/enums.ts
index 2b96bc23..89d28603 100644
--- a/src/types/enums.ts
+++ b/src/types/enums.ts
@@ -13,4 +13,5 @@ export enum RuleFunction {
   SCHEMAPATH = 'schemaPath',
   TRUTHY = 'truthy',
   XOR = 'xor',
+  TYPED_ENUM = 'typedEnum',
 }
diff --git a/src/types/rule.ts b/src/types/rule.ts
index 2d2ea760..600ef1a4 100644
--- a/src/types/rule.ts
+++ b/src/types/rule.ts
@@ -6,6 +6,7 @@ import { PatternRule } from '../functions/pattern';
 import { SchemaRule } from '../functions/schema';
 import { SchemaPathRule } from '../functions/schema-path';
 import { TruthyRule } from '../functions/truthy';
+import { TypedEnumRule } from '../functions/typedEnum';
 import { XorRule } from '../functions/xor';
 import { RuleType } from './enums';
 
@@ -18,7 +19,8 @@ export type Rule =
   | PatternRule
   | CasingRule
   | SchemaRule
-  | SchemaPathRule;
+  | SchemaPathRule
+  | TypedEnumRule;
 
 export interface IRule<T = string, O = any> {
   type?: RuleType;


Test patch:
diff --git a/src/functions/__tests__/typedEnum.test.ts b/src/functions/__tests__/typedEnum.test.ts
new file mode 100644
index 00000000..6fbfb648
--- /dev/null
+++ b/src/functions/__tests__/typedEnum.test.ts
@@ -0,0 +1,116 @@
+import { typedEnum } from '../typedEnum';
+
+const defaultReportingThreshold = 3;
+
+function runTypedEnum(targetVal: any, reportingThreshold: any) {
+  return typedEnum(
+    targetVal,
+    { reportingThreshold },
+    { given: ['$'] },
+    { given: null, original: null, resolved: {} as any },
+  );
+}
+
+describe('typedEnum', () => {
+  describe('parameters validation', () => {
+    test.each([1, { a: 1 }, 'nope', undefined])('is undefined when the enum is not an array (%s)', enumContent => {
+      const schema = {
+        type: 'integer',
+        enum: enumContent,
+      };
+
+      expect(runTypedEnum(schema, defaultReportingThreshold)).toBeUndefined();
+    });
+  });
+
+  test('is undefined when the enum contains no value', () => {
+    const schema = {
+      type: 'integer',
+      enum: [],
+    };
+
+    expect(runTypedEnum(schema, defaultReportingThreshold)).toBeUndefined();
+  });
+
+  describe('basic', () => {
+    test('is undefined when all enum values respect the type', () => {
+      const schema = {
+        type: 'integer',
+        enum: [123, 456],
+      };
+
+      expect(runTypedEnum(schema, defaultReportingThreshold)).toBeUndefined();
+    });
+
+    test('is undefined when all enum values respect the type', () => {
+      const schema = {
+        type: 'integer',
+        enum: [123, 456],
+      };
+
+      expect(runTypedEnum(schema, defaultReportingThreshold)).toBeUndefined();
+    });
+
+    test.each([undefined])('is undefined when type is "%s"', (typeValue: unknown) => {
+      const schema = {
+        type: typeValue,
+        enum: [123, 456],
+      };
+
+      expect(runTypedEnum(schema, defaultReportingThreshold)).toBeUndefined();
+    });
+
+    test('identifies enum values which do not respect the type', () => {
+      const schema = {
+        type: 'integer',
+        enum: [123, 'a string!', 456, 'and another one!'],
+      };
+
+      expect(runTypedEnum(schema, defaultReportingThreshold)).toEqual([
+        {
+          message: 'Enum value `a string!` does not respect the specified type `integer`.',
+          path: ['$', 'enum', 1],
+        },
+        {
+          message: 'Enum value `and another one!` does not respect the specified type `integer`.',
+          path: ['$', 'enum', 3],
+        },
+      ]);
+    });
+  });
+
+  describe('types', () => {
+    const testCases: Array<[string, unknown[], unknown]> = [
+      ['string', ['Hello', 'world!'], 12],
+      ['number', [-2147483648, 17.13], 'Hello'],
+      ['integer', [-2147483648, 17], 12.3],
+      ['boolean', [true, false], 1],
+    ];
+
+    test.each(testCases)(
+      'does not report anything when all the definitions are valid for type "%s"',
+      async (type: string, valids: unknown[], invalid: unknown) => {
+        const schema = {
+          type,
+          enum: valids,
+        };
+
+        expect(runTypedEnum(schema, defaultReportingThreshold)).toBeUndefined();
+      },
+    );
+
+    test.each(testCases)(
+      'identifies enum value which does not respect the type "%s"',
+      async (type: string, valids: unknown[], invalid: unknown) => {
+        const schema = {
+          type,
+          enum: [valids[0], invalid],
+        };
+
+        const results = runTypedEnum(schema, defaultReportingThreshold);
+
+        expect(results[0].message).toContain(`value \`${invalid}\``);
+      },
+    );
+  });
+});
diff --git a/src/rulesets/oas/__tests__/typed-enum.ts b/src/rulesets/oas/__tests__/typed-enum.ts
new file mode 100644
index 00000000..5d8b9038
--- /dev/null
+++ b/src/rulesets/oas/__tests__/typed-enum.ts
@@ -0,0 +1,133 @@
+import { DiagnosticSeverity } from '@stoplight/types';
+import { typedEnum } from '../../../functions/typedEnum';
+import { RuleType, Spectral } from '../../../index';
+import { rules } from '../index.json';
+
+describe('typed-enum', () => {
+  const s = new Spectral();
+  s.setFunctions({ typedEnum });
+  s.setRules({
+    'typed-enum': Object.assign(rules['typed-enum'], {
+      recommended: true,
+      type: RuleType[rules['typed-enum'].type],
+    }),
+  });
+
+  describe('oas2', () => {
+    test('does not report anything for empty object', async () => {
+      const results = await s.run({
+        swagger: '2.0',
+      });
+
+      expect(results).toEqual([]);
+    });
+
+    test('does not report anything when the model valid', async () => {
+      const doc = {
+        swagger: '2.0',
+        definitions: {
+          Test: {
+            type: 'integer',
+            enum: [1, 2, 3],
+          },
+        },
+      };
+
+      const results = await s.run(doc);
+
+      expect(results).toEqual([]);
+    });
+
+    test('identifies enum values which do not respect the type', async () => {
+      const doc = {
+        swagger: '2.0',
+        definitions: {
+          Test: {
+            type: 'integer',
+            enum: [1, 'a string!', 3, 'and another one!'],
+          },
+        },
+      };
+
+      const results = await s.run(doc);
+
+      expect(results).toEqual([
+        {
+          code: 'typed-enum',
+          message: 'Enum value `a string!` does not respect the specified type `integer`.',
+          path: ['definitions', 'Test', 'enum', '1'],
+          range: expect.any(Object),
+          severity: DiagnosticSeverity.Warning,
+        },
+        {
+          code: 'typed-enum',
+          message: 'Enum value `and another one!` does not respect the specified type `integer`.',
+          path: ['definitions', 'Test', 'enum', '3'],
+          range: expect.any(Object),
+          severity: DiagnosticSeverity.Warning,
+        },
+      ]);
+    });
+  });
+
+  describe('oas3', () => {
+    test('does not report anything for empty object', async () => {
+      const results = await s.run({
+        openapi: '3.0.0',
+      });
+
+      expect(results).toEqual([]);
+    });
+
+    test('does not report anything when the model is valid', async () => {
+      const doc = {
+        openapi: '3.0.0',
+        components: {
+          schemas: {
+            Test: {
+              type: 'integer',
+              enum: [1, 2, 3],
+            },
+          },
+        },
+      };
+
+      const results = await s.run(doc);
+
+      expect(results).toEqual([]);
+    });
+
+    test('identifies enum values which do not respect the type', async () => {
+      const doc = {
+        openapi: '3.0.0',
+        components: {
+          schemas: {
+            Test: {
+              type: 'integer',
+              enum: [1, 'a string!', 3, 'and another one!'],
+            },
+          },
+        },
+      };
+
+      const results = await s.run(doc);
+
+      expect(results).toEqual([
+        {
+          code: 'typed-enum',
+          message: 'Enum value `a string!` does not respect the specified type `integer`.',
+          path: ['components', 'schemas', 'Test', 'enum', '1'],
+          range: expect.any(Object),
+          severity: DiagnosticSeverity.Warning,
+        },
+        {
+          code: 'typed-enum',
+          message: 'Enum value `and another one!` does not respect the specified type `integer`.',
+          path: ['components', 'schemas', 'Test', 'enum', '3'],
+          range: expect.any(Object),
+          severity: DiagnosticSeverity.Warning,
+        },
+      ]);
+    });
+  });
+});
diff --git a/test-harness/scenarios/enabled-rules-amount.oas3.scenario b/test-harness/scenarios/enabled-rules-amount.oas3.scenario
index 06df64ec..9cbb763e 100644
--- a/test-harness/scenarios/enabled-rules-amount.oas3.scenario
+++ b/test-harness/scenarios/enabled-rules-amount.oas3.scenario
@@ -24,7 +24,7 @@ components:
 ====command====
 {bin} lint {document} --ruleset ./rulesets/parameter-description.oas3.yaml -v
 ====stdout====
-Found 53 rules (1 enabled)
+Found 54 rules (1 enabled)
 Linting {document}
 OpenAPI 3.x detected
 
diff --git a/test-harness/scenarios/typed-enum.oas3.scenario b/test-harness/scenarios/typed-enum.oas3.scenario
new file mode 100644
index 00000000..ca196fff
--- /dev/null
+++ b/test-harness/scenarios/typed-enum.oas3.scenario
@@ -0,0 +1,36 @@
+====test====
+Identify enum values that does not respect the specified type
+====asset:ruleset====
+extends: [[spectral:oas, off]]
+
+rules:
+  typed-enum: error
+====document====
+openapi: 3.0.2
+components:
+  schemas:
+    a_model:
+      type: object
+      properties:
+        id:
+          type: integer
+          description: Unique asset identifier
+          enum:
+            - 12
+            - Tap, tap, tap...
+            - Is thing thing on?
+            - 12254
+            - You certainly wonder
+            - Why I gathered you all today...
+====command====
+{bin} lint {document} --ruleset {asset:ruleset}
+====stdout====
+OpenAPI 3.x detected
+
+{document}
+ 12:15  error  typed-enum  Enum value `Tap, tap, tap...` does not respect the specified type `integer`.
+ 13:15  error  typed-enum  Enum value `Is thing thing on?` does not respect the specified type `integer`.
+ 15:15  error  typed-enum  Enum value `You certainly wonder` does not respect the specified type `integer`.
+ 16:15  error  typed-enum  Enum value `Why I gathered you all today...` does not respect the specified type `integer`.
+
+✖ 4 problems (4 errors, 0 warnings, 0 infos, 0 hints)