Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new rule to detect enum value that do not respect specified type #913

Merged
merged 2 commits into from
Jan 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
38 changes: 38 additions & 0 deletions docs/reference/openapi-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
116 changes: 116 additions & 0 deletions src/functions/__tests__/typedEnum.test.ts
Original file line number Diff line number Diff line change
@@ -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}\``);
},
);
});
});
1 change: 1 addition & 0 deletions src/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export const functions = {
undefined: require('./undefined').undefined,
xor: require('./xor').xor,
unreferencedReusableObject: require('./unreferencedReusableObject').unreferencedReusableObject,
typedEnum: require('./typedEnum').typedEnum,
};
40 changes: 40 additions & 0 deletions src/functions/typedEnum.ts
Original file line number Diff line number Diff line change
@@ -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],
};
});
};
133 changes: 133 additions & 0 deletions src/rulesets/oas/__tests__/typed-enum.ts
Original file line number Diff line number Diff line change
@@ -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,
},
]);
});
});
});
10 changes: 10 additions & 0 deletions src/rulesets/oas/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/types/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export enum RuleFunction {
SCHEMAPATH = 'schemaPath',
TRUTHY = 'truthy',
XOR = 'xor',
TYPED_ENUM = 'typedEnum',
}
4 changes: 3 additions & 1 deletion src/types/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,7 +19,8 @@ export type Rule =
| PatternRule
| CasingRule
| SchemaRule
| SchemaPathRule;
| SchemaPathRule
| TypedEnumRule;

export interface IRule<T = string, O = any> {
type?: RuleType;
Expand Down
Loading