From 1844f2b8e6da8bdaa990f6cf5f2c4bcc61a069bb Mon Sep 17 00:00:00 2001 From: nulltoken Date: Mon, 13 Jan 2020 08:35:50 +0100 Subject: [PATCH] feat: new typed-enum rule --- docs/reference/functions.md | 4 + docs/reference/openapi-rules.md | 38 ++++ src/functions/__tests__/typedEnum.test.ts | 114 ++++++++++++ src/functions/index.ts | 1 + src/functions/typedEnum.ts | 40 +++++ src/rulesets/oas/__tests__/typed-enum.ts | 168 ++++++++++++++++++ src/rulesets/oas/index.json | 13 ++ src/types/enums.ts | 1 + src/types/rule.ts | 4 +- src/utils/prepareResults.ts | 2 + .../enabled-rules-amount.oas3.scenario | 2 +- .../scenarios/typed-enum.oas3.scenario | 36 ++++ 12 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 src/functions/__tests__/typedEnum.test.ts create mode 100644 src/functions/typedEnum.ts create mode 100644 src/rulesets/oas/__tests__/typed-enum.ts create mode 100644 test-harness/scenarios/typed-enum.oas3.scenario diff --git a/docs/reference/functions.md b/docs/reference/functions.md index 76bfc1280..581382976 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 efd3d917d..388e0e000 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/__tests__/typedEnum.test.ts b/src/functions/__tests__/typedEnum.test.ts new file mode 100644 index 000000000..5c7aeaf79 --- /dev/null +++ b/src/functions/__tests__/typedEnum.test.ts @@ -0,0 +1,114 @@ +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!" do not respect the specified type "integer".', + }, + { + message: 'Enum value "and another one!" do not respect the specified type "integer".', + }, + ]); + }); + }); + + 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/functions/index.ts b/src/functions/index.ts index 49c4dde4a..79c4e72a7 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 000000000..c9c4e41a6 --- /dev/null +++ b/src/functions/typedEnum.ts @@ -0,0 +1,40 @@ +import { isArray } from 'lodash'; +import { IFunction, IFunctionResult, IRule, RuleFunction } from '../types'; +import { schema } from './schema'; + +export type TypedEnumRule = IRule; + +export const typedEnum: IFunction = (targetVal, opts, paths, otherValues): void | IFunctionResult[] => { + const { enum: enumValues, ...initialSchema } = targetVal; + + if (!isArray(enumValues)) { + return; + } + + const innerSchema = { type: initialSchema.type, enum: initialSchema.enum }; + const schemaObject = { schema: innerSchema }; + + const incorrectValues: unknown[] = []; + + for (const val of enumValues) { + const res = schema(val, schemaObject, paths, otherValues); + + if (res === undefined || res.length === 0) { + continue; + } + + incorrectValues.push(val); + } + + if (incorrectValues.length === 0) { + return; + } + + const { type } = initialSchema; + + return incorrectValues.map(val => { + return { + message: `Enum value "${val}" do not respect the specified type "${type}".`, + }; + }); +}; diff --git a/src/rulesets/oas/__tests__/typed-enum.ts b/src/rulesets/oas/__tests__/typed-enum.ts new file mode 100644 index 000000000..1ac443c60 --- /dev/null +++ b/src/rulesets/oas/__tests__/typed-enum.ts @@ -0,0 +1,168 @@ +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!" do not respect the specified type "integer".', + path: ['definitions', 'Test'], + range: { + start: { + character: 11, + line: 3, + }, + end: { + character: 26, + line: 9, + }, + }, + severity: 1, + }, + { + code: 'typed-enum', + message: 'Enum value "and another one!" do not respect the specified type "integer".', + path: ['definitions', 'Test'], + range: { + start: { + character: 11, + line: 3, + }, + end: { + character: 26, + line: 9, + }, + }, + severity: 1, + }, + ]); + }); + }); + + 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!" do not respect the specified type "integer".', + path: ['components', 'schemas', 'Test'], + range: { + start: { + character: 13, + line: 4, + }, + end: { + character: 28, + line: 10, + }, + }, + severity: 1, + }, + { + code: 'typed-enum', + message: 'Enum value "and another one!" do not respect the specified type "integer".', + path: ['components', 'schemas', 'Test'], + range: { + start: { + character: 13, + line: 4, + }, + end: { + character: 28, + line: 10, + }, + }, + severity: 1, + }, + ]); + }); + }); +}); diff --git a/src/rulesets/oas/index.json b/src/rulesets/oas/index.json index be7aafbd1..69f349185 100644 --- a/src/rulesets/oas/index.json +++ b/src/rulesets/oas/index.json @@ -416,6 +416,19 @@ "function": "refSiblings" } }, + "typed-enum": { + "description": "Enum values should respect the specified type.", + "message": "{{error}}", + "recommended": true, + "type": "validation", + "given": "$..[?(@.enum && @.type)]", + "then": { + "function": "typedEnum", + "functionOptions": { + "reportingThreshold": 3 + } + } + }, "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 2b96bc23b..89d286033 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 2d2ea760e..600ef1a49 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 { type?: RuleType; diff --git a/src/utils/prepareResults.ts b/src/utils/prepareResults.ts index 26001c541..3c817e98c 100644 --- a/src/utils/prepareResults.ts +++ b/src/utils/prepareResults.ts @@ -18,6 +18,8 @@ export const defaultComputeResultFingerprint: ComputeFingerprintFunc = (rule, ha if (rule.source) id += rule.source; + if (rule.message) id += rule.message; + return hash(id); }; diff --git a/test-harness/scenarios/enabled-rules-amount.oas3.scenario b/test-harness/scenarios/enabled-rules-amount.oas3.scenario index 06df64ecd..9cbb763ec 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 000000000..dc89864d5 --- /dev/null +++ b/test-harness/scenarios/typed-enum.oas3.scenario @@ -0,0 +1,36 @@ +====test==== +Identify enum values that do 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} + 7:12 error typed-enum Enum value "Tap, tap, tap..." do not respect the specified type "integer". + 7:12 error typed-enum Enum value "Is thing thing on?" do not respect the specified type "integer". + 7:12 error typed-enum Enum value "You certainly wonder" do not respect the specified type "integer". + 7:12 error typed-enum Enum value "Why I gathered you all today..." do not respect the specified type "integer". + +✖ 4 problems (4 errors, 0 warnings, 0 infos, 0 hints)