Repository: stoplightio/spectral
Pull request #1846 (2021-09-22 12:29:28)

perf(rulesets): typedEnum does not need schema function
Related to https://github.com/stoplightio/spectral/issues/1885.
In general, while the use of `schema` function may seem convenient, it's slower than a hand-baked solution that's about the same complex.
I also addressed one fallout - we still used grave accent (`) instead of quotes.

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No




Patch:
diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json
index 7bb86bb4..e0ac2fc4 100644
--- a/packages/rulesets/package.json
+++ b/packages/rulesets/package.json
@@ -19,10 +19,11 @@
   },
   "dependencies": {
     "@stoplight/better-ajv-errors": "0.2.0",
-    "@stoplight/json": "3.15.0",
+    "@stoplight/json": "3.17.0",
     "@stoplight/spectral-core": "^1.3.0",
     "@stoplight/spectral-formats": "^1.0.1",
     "@stoplight/spectral-functions": "^1.1.2",
+    "@stoplight/spectral-runtime": "^1.0.0",
     "@stoplight/types": "^12.3.0",
     "@types/json-schema": "^7.0.7",
     "ajv": "~8.6.0",
diff --git a/packages/rulesets/src/oas/functions/typedEnum.ts b/packages/rulesets/src/oas/functions/typedEnum.ts
index 92033a73..25bf4f84 100644
--- a/packages/rulesets/src/oas/functions/typedEnum.ts
+++ b/packages/rulesets/src/oas/functions/typedEnum.ts
@@ -1,70 +1,102 @@
-import { schema } from '@stoplight/spectral-functions';
-import { oas2, oas3 } from '@stoplight/spectral-formats';
-import type { IFunction } from '@stoplight/spectral-core';
-import { isObject } from './utils/isObject';
+import { oas2, oas3_0 } from '@stoplight/spectral-formats';
+import { printValue } from '@stoplight/spectral-runtime';
+import { createRulesetFunction, Document, IFunctionResult } from '@stoplight/spectral-core';
+import type { JSONSchema4TypeName, JSONSchema6TypeName, JSONSchema7TypeName } from 'json-schema';
 
-export const typedEnum: IFunction = function (targetVal, opts, context) {
-  if (!isObject(targetVal)) {
-    return;
-  }
+function getDataType(
+  input: unknown,
+  checkForInteger: boolean,
+): JSONSchema4TypeName | JSONSchema6TypeName | JSONSchema7TypeName {
+  const type = typeof input;
+  switch (type) {
+    case 'string':
+    case 'boolean':
+      return type;
+    case 'number':
+      if (checkForInteger && Number.isInteger(input)) {
+        return 'integer';
+      }
 
-  if (targetVal.enum === null || targetVal.enum === void 0 || targetVal.type === null || targetVal.type === void 0) {
-    return;
-  }
-  // do not use rest spread operator here, as this causes the whole tslib gets injected despite proper target set...
-  // obviously, having tslib inlined makes the code size quite larger (around 4x after compression - 1.8K vs 7.4K).
-  const { enum: enumValues } = targetVal;
-  const initialSchema = Object.assign({}, targetVal);
-  delete initialSchema.enum;
+      return 'number';
+    case 'object':
+      if (input === null) {
+        return 'null';
+      }
 
-  if (!Array.isArray(enumValues)) {
-    return;
+      return Array.isArray(input) ? 'array' : 'object';
+    default:
+      throw TypeError('Unknown input type');
   }
+}
 
-  const { document } = context;
-  const isOAS3 = document.formats?.has(oas3) === true;
-  const isOAS2 = document.formats?.has(oas2) === true;
+type Input = {
+  enum: unknown[];
+  type: string[] | string;
+  [key: string]: unknown;
+};
 
-  let innerSchema;
-  if ((isOAS3 && targetVal.nullable === true) || (isOAS2 && targetVal['x-nullable'] === true)) {
-    const type = Array.isArray(initialSchema.type)
-      ? [...(initialSchema.type as unknown[])]
-      : initialSchema.type !== void 0
-      ? [initialSchema.type]
-      : [];
-    if (!type.includes('null')) {
-      type.push('null');
-    }
+function getTypes(input: Input, formats: Document['formats']): string | string[] {
+  const { type } = input;
 
-    innerSchema = { type, enum: initialSchema.enum };
-  } else {
-    innerSchema = { type: initialSchema.type, enum: initialSchema.enum };
+  if (
+    (input.nullable === true && formats?.has(oas3_0) === true) ||
+    (input['x-nullable'] === true && formats?.has(oas2) === true)
+  ) {
+    return Array.isArray(type) ? [...type, 'null'] : [type, 'null'];
   }
 
-  const schemaObject = { schema: innerSchema };
+  return type;
+}
 
-  const incorrectValues: Array<{ index: number; val: unknown }> = [];
+export const typedEnum = createRulesetFunction<Input, null>(
+  {
+    input: {
+      type: 'object',
+      properties: {
+        enum: {
+          type: 'array',
+        },
+        type: {
+          oneOf: [
+            {
+              type: 'array',
+              items: {
+                type: 'string',
+              },
+            },
+            {
+              type: 'string',
+            },
+          ],
+        },
+      },
+      required: ['enum', 'type'],
+    },
+    options: null,
+  },
+  function (input, opts, context) {
+    const { enum: enumValues } = input;
+    const type = getTypes(input, context.document.formats);
+    const checkForInteger = type === 'integer' || (Array.isArray(type) && type.includes('integer'));
 
-  (enumValues as unknown[]).forEach((val, index) => {
-    const res = schema(val, schemaObject, context);
+    let results: IFunctionResult[] | undefined;
 
-    if (Array.isArray(res) && res.length !== 0) {
-      incorrectValues.push({ index, val });
-    }
-  });
+    enumValues.forEach((value, i) => {
+      const valueType = getDataType(value, checkForInteger);
 
-  if (incorrectValues.length === 0) {
-    return;
-  }
+      if (valueType === type || (Array.isArray(type) && type.includes(valueType))) {
+        return;
+      }
 
-  const { type } = initialSchema;
+      results ??= [];
+      results.push({
+        message: `Enum value ${printValue(enumValues[i])} must be "${String(type)}".`,
+        path: [...context.path, 'enum', i],
+      });
+    });
 
-  return incorrectValues.map(bad => {
-    return {
-      message: `Enum value \`${String(bad.val)}\` must be "${String(type)}".`,
-      path: [...context.path, 'enum', bad.index],
-    };
-  });
-};
+    return results;
+  },
+);
 
 export default typedEnum;


Test patch:
diff --git a/packages/rulesets/src/oas/__tests__/typed-enum.test.ts b/packages/rulesets/src/oas/__tests__/typed-enum.test.ts
index cb07e559..afe02b46 100644
--- a/packages/rulesets/src/oas/__tests__/typed-enum.test.ts
+++ b/packages/rulesets/src/oas/__tests__/typed-enum.test.ts
@@ -37,12 +37,12 @@ testRule('typed-enum', [
     },
     errors: [
       {
-        message: 'Enum value `a string!` must be "integer".',
+        message: 'Enum value "a string!" must be "integer".',
         path: ['definitions', 'Test', 'enum', '1'],
         severity: DiagnosticSeverity.Warning,
       },
       {
-        message: 'Enum value `and another one!` must be "integer".',
+        message: 'Enum value "and another one!" must be "integer".',
         path: ['definitions', 'Test', 'enum', '3'],
         severity: DiagnosticSeverity.Warning,
       },
@@ -64,7 +64,7 @@ testRule('typed-enum', [
     errors: [
       {
         code: 'typed-enum',
-        message: 'Enum value `null` must be "string".',
+        message: 'Enum value null must be "string".',
         path: ['definitions', 'Test', 'enum', '2'],
         range: expect.any(Object),
         severity: DiagnosticSeverity.Warning,
@@ -126,12 +126,12 @@ testRule('typed-enum', [
     },
     errors: [
       {
-        message: 'Enum value `a string!` must be "integer".',
+        message: 'Enum value "a string!" must be "integer".',
         path: ['components', 'schemas', 'Test', 'enum', '1'],
         severity: DiagnosticSeverity.Warning,
       },
       {
-        message: 'Enum value `and another one!` must be "integer".',
+        message: 'Enum value "and another one!" must be "integer".',
         path: ['components', 'schemas', 'Test', 'enum', '3'],
         severity: DiagnosticSeverity.Warning,
       },
diff --git a/packages/rulesets/src/oas/functions/__tests__/typedEnum.test.ts b/packages/rulesets/src/oas/functions/__tests__/typedEnum.test.ts
index 7c1c6473..22cb11f3 100644
--- a/packages/rulesets/src/oas/functions/__tests__/typedEnum.test.ts
+++ b/packages/rulesets/src/oas/functions/__tests__/typedEnum.test.ts
@@ -72,11 +72,11 @@ describe('typedEnum', () => {
 
       expect(runTypedEnum(schema)).toEqual([
         {
-          message: 'Enum value `a string!` must be "integer".',
+          message: 'Enum value "a string!" must be "integer".',
           path: ['enum', 1],
         },
         {
-          message: 'Enum value `and another one!` must be "integer".',
+          message: 'Enum value "and another one!" must be "integer".',
           path: ['enum', 3],
         },
       ]);
@@ -113,7 +113,7 @@ describe('typedEnum', () => {
 
         const results = runTypedEnum(schema);
 
-        expect(results[0].message).toContain(`value \`${invalid}\``);
+        expect(results[0].message).toContain(`Enum value`);
       },
     );
   });
diff --git a/test-harness/scenarios/typed-enum.oas3.scenario b/test-harness/scenarios/typed-enum.oas3.scenario
index 9c6e0828..af1a1dae 100644
--- a/test-harness/scenarios/typed-enum.oas3.scenario
+++ b/test-harness/scenarios/typed-enum.oas3.scenario
@@ -30,9 +30,9 @@ components:
 {bin} lint {document} --ruleset "{asset:ruleset}"
 ====stdout====
 {document}
- 12:15  error  typed-enum  Enum value `Tap, tap, tap...` must be "integer".                 components.schemas.a_model.properties.id.enum[1]
- 13:15  error  typed-enum  Enum value `Is thing thing on?` must be "integer".               components.schemas.a_model.properties.id.enum[2]
- 15:15  error  typed-enum  Enum value `You certainly wonder` must be "integer".             components.schemas.a_model.properties.id.enum[4]
- 16:15  error  typed-enum  Enum value `Why I gathered you all today...` must be "integer".  components.schemas.a_model.properties.id.enum[5]
+ 12:15  error  typed-enum  Enum value "Tap, tap, tap..." must be "integer".                 components.schemas.a_model.properties.id.enum[1]
+ 13:15  error  typed-enum  Enum value "Is thing thing on?" must be "integer".               components.schemas.a_model.properties.id.enum[2]
+ 15:15  error  typed-enum  Enum value "You certainly wonder" must be "integer".             components.schemas.a_model.properties.id.enum[4]
+ 16:15  error  typed-enum  Enum value "Why I gathered you all today..." must be "integer".  components.schemas.a_model.properties.id.enum[5]
 
 ✖ 4 problems (4 errors, 0 warnings, 0 infos, 0 hints)