Skip to content

Commit

Permalink
feat: teach rulesets to accept exceptions
Browse files Browse the repository at this point in the history
  • Loading branch information
nulltoken committed Feb 16, 2020
1 parent 55c1a24 commit 1b0752f
Show file tree
Hide file tree
Showing 23 changed files with 829 additions and 9 deletions.
5 changes: 5 additions & 0 deletions src/__tests__/__fixtures__/custom-oas-ruleset.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,10 @@
}
}
}
},
"except": {
"/test/file.json#/info": ["info-contact", "info-description"],
"/test/file.json#": [ "oas3-api-servers"],
"another.yaml#": ["dummy-rule", "info-contact"]
}
}
17 changes: 17 additions & 0 deletions src/__tests__/__fixtures__/exceptions.remote.oas3.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
%YAML 1.2
---
openapi: 3.0.2
info:
title: Random title
description: Random description
version: 0.0.1
contact:
email: [email protected]
paths: {}
servers:
- url: https://www.random.com
components:
schemas:
TheRemoteType:
description: A strongly typed string
type: string
180 changes: 178 additions & 2 deletions src/__tests__/linter.jest.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { normalize } from '@stoplight/path';
import { DiagnosticSeverity } from '@stoplight/types';
import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
import * as path from 'path';
import { isOpenApiv3 } from '../formats';
import { httpAndFileResolver } from '../resolvers/http-and-file';
import { Spectral } from '../spectral';
import { Rule, RuleType, Spectral } from '../spectral';

import { rules as oasRules } from '../rulesets/oas/index.json';

const customFunctionOASRuleset = path.join(__dirname, './__fixtures__/custom-functions-oas-ruleset.json');
const customOASRuleset = path.join(__dirname, './__fixtures__/custom-oas-ruleset.json');
const customDirectoryFunctionsRuleset = path.join(__dirname, './__fixtures__/custom-directory-function-ruleset.json');

describe('Linter', () => {
Expand Down Expand Up @@ -170,4 +174,176 @@ describe('Linter', () => {
);
});
});

describe('Exceptions handling', () => {
it('should ignore specified rules violations in a standalone document', async () => {
await spectral.loadRuleset(customOASRuleset);
spectral.registerFormat('oas3', isOpenApiv3);

const res = await spectral.run(
{
openapi: '3.0.2',
info: 17,
},
{
resolve: {
documentUri: '/test/file.json',
},
},
);

expect(res.length).toBeGreaterThan(0);

expect(res).not.toContainEqual(
expect.objectContaining({
code: 'info-contact',
}),
);

expect(res).not.toContainEqual(
expect.objectContaining({
code: 'info-description',
}),
);

expect(res).not.toContainEqual(
expect.objectContaining({
code: 'oas3-api-servers',
}),
);
});

describe('resolving', () => {
const testRules: Dictionary<Rule, string> = {
'no-yaml-remote-reference': {
formats: ['oas2', 'oas3'],
message: '$ref must not point at yaml files',
given: '$..$ref',
recommended: true,
resolved: false,
then: {
function: 'pattern',
functionOptions: {
notMatch: '\\.yaml(#.*)$',
},
},
},
'strings-maxLength': {
formats: ['oas2', 'oas3'],
message: "String typed properties MUST be further described using 'maxLength'. Error: {{error}}",
given: "$..*[?(@.type === 'string')]",
recommended: true,
then: {
field: 'maxLength',
function: 'truthy',
},
},
'oas3-schema': {
...oasRules['oas3-schema'],
type: RuleType[oasRules['oas2-unused-definition'].type],
},
};

const remoteFile = './__fixtures__/exceptions.remote.oas3.yaml';
const rootedRemoteFile = path.join(__dirname, remoteFile);
const rootedRemoteReference = rootedRemoteFile + '#/components/schemas/TheRemoteType';

const localFile = 'foo.json';
const rootedLocalFile = path.join(__dirname, localFile);
const rootedLocalReference = rootedLocalFile + '#/components/schemas/TheLocalType/$ref';

const testExceptions: Dictionary<string[], string> = {};
testExceptions[rootedRemoteReference] = ['strings-maxLength'];
testExceptions[rootedLocalReference] = ['no-yaml-remote-reference'];

const document = {
openapi: '3.0.2',
components: {
schemas: {
TheLocalType: {
$ref: remoteFile + '#/components/schemas/TheRemoteType',
},
},
},
};

const opts = {
resolve: {
documentUri: rootedLocalFile,
},
};

it('should ignore specified rules violations in a referenced document', async () => {
spectral = new Spectral({ resolver: httpAndFileResolver });
spectral.registerFormat('oas3', isOpenApiv3);

const rules = {
'strings-maxLength': testRules['strings-maxLength'],
'oas3-schema': testRules['oas3-schema'],
};

spectral.setRuleset({ rules, exceptions: {}, functions: {} });

const first = await spectral.run(document, opts);

expect(first).toEqual([
expect.objectContaining({
code: 'strings-maxLength',
}),
expect.objectContaining({
code: 'oas3-schema',
}),
]);

const exceptions = {};
exceptions[rootedRemoteReference] = testExceptions[rootedRemoteReference];

spectral.setRuleset({ rules, exceptions, functions: {} });

const second = await spectral.run(document, opts);

expect(second).toEqual([
expect.objectContaining({
code: 'oas3-schema',
}),
]);
});

it('should ignore specified rules violations in "resolved=false" mode', async () => {
spectral = new Spectral({ resolver: httpAndFileResolver });
spectral.registerFormat('oas3', isOpenApiv3);

const rules = {
'no-yaml-remote-reference': testRules['no-yaml-remote-reference'],
'oas3-schema': testRules['oas3-schema'],
};

spectral.setRuleset({ rules, exceptions: {}, functions: {} });

const first = await spectral.run(document, opts);

expect(first).toEqual([
expect.objectContaining({
code: 'oas3-schema',
}),
expect.objectContaining({
code: 'no-yaml-remote-reference',
}),
]);

const exceptions = {};
exceptions[rootedLocalReference] = testExceptions[rootedLocalReference];

spectral.setRuleset({ rules, exceptions, functions: {} });

const second = await spectral.run(document, opts);

expect(second).toEqual([
expect.objectContaining({
code: 'oas3-schema',
}),
]);
});
});
});
});
8 changes: 8 additions & 0 deletions src/__tests__/spectral.jest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ describe('Spectral', () => {
},
}),
);

Object.keys(s.exceptions).forEach(p => expect(path.isAbsolute(p)).toEqual(true));

expect(Object.entries(s.exceptions)).toEqual([
[expect.stringMatching('^/test/file.json#/info$'), ['info-contact', 'info-description']],
[expect.stringMatching('^/test/file.json#$'), ['oas3-api-servers']],
[expect.stringMatching('/__tests__/__fixtures__/another.yaml#$'), ['dummy-rule', 'info-contact']],
]);
});

test('should support loading rulesets over http', async () => {
Expand Down
50 changes: 49 additions & 1 deletion src/__tests__/spectral.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { IGraphNodeData } from '@stoplight/json-ref-resolver/types';
import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
import { DepGraph } from 'dependency-graph';
import { merge } from 'lodash';
import { escapeRegExp, merge } from 'lodash';

import { Document } from '../document';
import * as Parsers from '../parsers';
import { Spectral } from '../spectral';
import { IResolver, IRunRule, RuleFunction } from '../types';
import { RulesetExceptionCollection } from '../types/ruleset';

import { buildRulesetExceptionCollectionFrom } from '../rulesets/mergers/__tests__/exceptions.test';

const oasRuleset = JSON.parse(JSON.stringify(require('../rulesets/oas/index.json')));
const oasRulesetRules: Dictionary<IRunRule, string> = oasRuleset.rules;
Expand Down Expand Up @@ -258,4 +261,49 @@ describe('spectral', () => {
});
});
});

describe('setRuleset', () => {
const s = new Spectral();

describe('exceptions handling', () => {
it.each([['one.yaml#'], ['one.yaml#/'], ['one.yaml#/toto'], ['down/one.yaml#/toto'], ['../one.yaml#/toto']])(
'throws on relative locations (location: "%s")',
location => {
const exceptions = buildRulesetExceptionCollectionFrom(location);

expect(() => {
s.setRuleset({ rules: {}, functions: {}, exceptions });
}).toThrow(new RegExp(`.+\`${escapeRegExp(location)}\`.+is not a valid uri.+Only absolute Uris are allowed`));
},
);

it.each([
['https://dot.com/one.yaml#/toto', 'https://dot.com/one.yaml#/toto'],
['/local/one.yaml#/toto', '/local/one.yaml#/toto'],
['c:/one.yaml#/toto', 'c:/one.yaml#/toto'],
['c:\\one.yaml#/toto', 'c:/one.yaml#/toto'],
])('normalizes absolute locations (location: "%s")', (location, expected) => {
const exceptions = buildRulesetExceptionCollectionFrom(location);

s.setRuleset({ rules: {}, functions: {}, exceptions });

const locs = Object.keys(s.exceptions);
expect(locs).toEqual([expected]);
});

it('normalizes exceptions', () => {
const exceptions: RulesetExceptionCollection = {
'/test/file.yaml#/a': ['f', 'c', 'd', 'a'],
'/test/file.yaml#/b': ['1', '3', '3', '2'],
};

s.setRuleset({ rules: {}, functions: {}, exceptions });

expect(s.exceptions).toEqual({
'/test/file.yaml#/a': ['a', 'c', 'd', 'f'],
'/test/file.yaml#/b': ['1', '2', '3'],
});
});
});
});
});
1 change: 1 addition & 0 deletions src/cli/services/linter/utils/getRuleset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ async function loadRulesets(cwd: string, rulesetFiles: string[]): Promise<IRules
return {
functions: {},
rules: {},
exceptions: {},
};
}

Expand Down
31 changes: 30 additions & 1 deletion src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IMessageVars, message } from './rulesets/message';
import { getDiagnosticSeverity } from './rulesets/severity';
import { IFunction, IGivenNode, IRuleResult, IRunRule, IThen } from './types';
import { getClosestJsonPath, getLintTargets, printPath, PrintStyle } from './utils';
import { IExceptionLocation } from './utils/pivotExceptions';

// TODO(SO-23): unit test but mock whatShouldBeLinted
export const lintNode = (
Expand All @@ -15,6 +16,7 @@ export const lintNode = (
then: IThen<string, any>,
apply: IFunction,
inventory: DocumentInventory,
exceptionLocations: IExceptionLocation[] | undefined,
): IRuleResult[] => {
const givenPath = node.path[0] === '$' ? node.path.slice(1) : node.path;
const targets = getLintTargets(node.value, then.field);
Expand Down Expand Up @@ -76,5 +78,32 @@ export const lintNode = (
);
}

return results;
const isAKnownException = (violation: IRuleResult, locations: IExceptionLocation[]): boolean => {
for (const location of locations) {
if (violation.source !== location.source) {
continue;
}

if (violation.path.length !== location.path.length) {
continue;
}

for (let i = 0; i < violation.path.length; i++) {
if (location.path[i] !== violation.path[i]) {
continue;
}
}

return true;
}

return false;
};

if (exceptionLocations === undefined) {
return results;
}

const filtered = results.filter(r => !isAKnownException(r, exceptionLocations));
return filtered;
};
9 changes: 9 additions & 0 deletions src/meta/ruleset.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@
}
]
}
},
"except": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"anyOf": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
extends: ["./standalone.yaml"]
except:
"four.yaml#": ["my-rule-4"]
3 changes: 3 additions & 0 deletions src/rulesets/__tests__/__fixtures__/exceptions/invalid.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
extends: [[spectral:oas, off]]
except:
"one.yaml": ["my-rule"]
5 changes: 5 additions & 0 deletions src/rulesets/__tests__/__fixtures__/exceptions/simple.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
extends: [[spectral:oas, off]]
except:
"one.yaml#": ["my-rule-1"]
"../two.yaml#": ["my-rule-2"]
"sub/three.yaml#": ["my-rule-3"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
rules: {}
except:
"one.yaml#": ["my-rule-1"]
"../two.yaml#": ["my-rule-2"]
"sub/three.yaml#": ["my-rule-3"]
Loading

0 comments on commit 1b0752f

Please sign in to comment.