Skip to content

Commit

Permalink
feat: new typed-enum rule
Browse files Browse the repository at this point in the history
  • Loading branch information
nulltoken committed Jan 21, 2020
1 parent 28e1ecc commit fe655ec
Show file tree
Hide file tree
Showing 11 changed files with 383 additions and 2 deletions.
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

0 comments on commit fe655ec

Please sign in to comment.