Skip to content

Commit e83f1cb

Browse files
CLOUDP-288860: Support IPA exception and validate exception extensions
1 parent 98f5b23 commit e83f1cb

11 files changed

+318
-5
lines changed

tools/spectral/ipa/__tests__/eachPathAlternatesBetweenResourceNameAndPathParam.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,26 @@ testRule('xgen-IPA-102-path-alternate-resource-name-path-param', [
134134
},
135135
],
136136
},
137+
{
138+
name: 'invalid paths with exceptions',
139+
document: {
140+
paths: {
141+
'/api/atlas/v2/unauth/resourceName1/resourceName2': {
142+
'x-xgen-IPA-exception': {
143+
'xgen-IPA-102-path-alternate-resource-name-path-param': {
144+
reason: 'test',
145+
},
146+
},
147+
},
148+
'/api/atlas/v2/resourceName/{pathParam1}/{pathParam2}': {
149+
'x-xgen-IPA-exception': {
150+
'xgen-IPA-102-path-alternate-resource-name-path-param': {
151+
reason: 'test',
152+
},
153+
},
154+
},
155+
},
156+
},
157+
errors: [],
158+
},
137159
]);

tools/spectral/ipa/__tests__/eachResourceHasGetMethod.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,25 @@ testRule('xgen-IPA-104-resource-has-GET', [
125125
},
126126
],
127127
},
128+
{
129+
name: 'invalid method with exception',
130+
document: {
131+
paths: {
132+
'/standard': {
133+
post: {},
134+
get: {},
135+
'x-xgen-IPA-exception': {
136+
'xgen-IPA-104-resource-has-GET': {
137+
reason: 'test',
138+
},
139+
},
140+
},
141+
'/standard/{exampleId}': {
142+
patch: {},
143+
delete: {},
144+
},
145+
},
146+
},
147+
errors: [],
148+
},
128149
]);
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import testRule from './__helpers__/testRule';
2+
import { DiagnosticSeverity } from '@stoplight/types';
3+
4+
testRule('xgen-IPA-005-exception-extension-format', [
5+
{
6+
name: 'valid exceptions',
7+
document: {
8+
paths: {
9+
'/path': {
10+
'x-xgen-IPA-exception': {
11+
'xgen-IPA-100-rule-name': {
12+
reason: 'Exception',
13+
},
14+
},
15+
},
16+
'/nested': {
17+
post: {
18+
'x-xgen-IPA-exception': {
19+
'xgen-IPA-100-rule-name': {
20+
reason: 'Exception',
21+
},
22+
},
23+
},
24+
},
25+
},
26+
},
27+
errors: [],
28+
},
29+
{
30+
name: 'invalid exceptions',
31+
document: {
32+
paths: {
33+
'/path1': {
34+
'x-xgen-IPA-exception': 'Exception',
35+
},
36+
'/path2': {
37+
'x-xgen-IPA-exception': {
38+
'xgen-IPA-100-rule-name': 'Exception',
39+
},
40+
},
41+
'/path3': {
42+
'x-xgen-IPA-exception': {
43+
'xgen-IPA-100-rule-name': {
44+
reason: '',
45+
},
46+
},
47+
},
48+
'/path4': {
49+
'x-xgen-IPA-exception': {
50+
'invalid-rule-name': {
51+
reason: 'Exception',
52+
},
53+
},
54+
},
55+
'/path5': {
56+
'x-xgen-IPA-exception': {
57+
'xgen-IPA-100-rule-name': {
58+
wrongKey: 'Exception',
59+
},
60+
},
61+
},
62+
'/path6': {
63+
'x-xgen-IPA-exception': {
64+
'xgen-IPA-100-rule-name': {
65+
reason: 'Exception',
66+
excessKey: 'Exception',
67+
},
68+
},
69+
},
70+
'/path7': {
71+
'x-xgen-IPA-exception': {
72+
'xgen-IPA-100-rule-name': {},
73+
},
74+
},
75+
},
76+
},
77+
errors: [
78+
{
79+
code: 'xgen-IPA-005-exception-extension-format',
80+
message: 'IPA exceptions must have a valid rule name and a reason. http://go/ipa/5',
81+
path: ['paths', '/path1', 'x-xgen-IPA-exception'],
82+
severity: DiagnosticSeverity.Warning,
83+
},
84+
{
85+
code: 'xgen-IPA-005-exception-extension-format',
86+
message: 'IPA exceptions must have a valid rule name and a reason. http://go/ipa/5',
87+
path: ['paths', '/path2', 'x-xgen-IPA-exception', 'xgen-IPA-100-rule-name'],
88+
severity: DiagnosticSeverity.Warning,
89+
},
90+
{
91+
code: 'xgen-IPA-005-exception-extension-format',
92+
message: 'IPA exceptions must have a valid rule name and a reason. http://go/ipa/5',
93+
path: ['paths', '/path3', 'x-xgen-IPA-exception', 'xgen-IPA-100-rule-name'],
94+
severity: DiagnosticSeverity.Warning,
95+
},
96+
{
97+
code: 'xgen-IPA-005-exception-extension-format',
98+
message: 'IPA exceptions must have a valid rule name and a reason. http://go/ipa/5',
99+
path: ['paths', '/path4', 'x-xgen-IPA-exception', 'invalid-rule-name'],
100+
severity: DiagnosticSeverity.Warning,
101+
},
102+
{
103+
code: 'xgen-IPA-005-exception-extension-format',
104+
message: 'IPA exceptions must have a valid rule name and a reason. http://go/ipa/5',
105+
path: ['paths', '/path5', 'x-xgen-IPA-exception', 'xgen-IPA-100-rule-name'],
106+
severity: DiagnosticSeverity.Warning,
107+
},
108+
{
109+
code: 'xgen-IPA-005-exception-extension-format',
110+
message: 'IPA exceptions must have a valid rule name and a reason. http://go/ipa/5',
111+
path: ['paths', '/path6', 'x-xgen-IPA-exception', 'xgen-IPA-100-rule-name'],
112+
severity: DiagnosticSeverity.Warning,
113+
},
114+
{
115+
code: 'xgen-IPA-005-exception-extension-format',
116+
message: 'IPA exceptions must have a valid rule name and a reason. http://go/ipa/5',
117+
path: ['paths', '/path7', 'x-xgen-IPA-exception', 'xgen-IPA-100-rule-name'],
118+
severity: DiagnosticSeverity.Warning,
119+
},
120+
],
121+
},
122+
]);
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
import { hasException } from '../../rulesets/functions/utils/exceptions';
3+
4+
const TEST_RULE_NAME_100 = 'xgen-IPA-100';
5+
6+
const objectWithIpa100Exception = {
7+
'x-xgen-IPA-exception': {
8+
'xgen-IPA-100': {
9+
reason: 'test',
10+
},
11+
},
12+
};
13+
14+
const objectWithNestedIpa100Exception = {
15+
get: {
16+
'x-xgen-IPA-exception': {
17+
'xgen-IPA-100': {
18+
reason: 'test',
19+
},
20+
},
21+
},
22+
};
23+
24+
const objectWithIpa100ExceptionAndOwnerExtension = {
25+
'x-xgen-IPA-exception': {
26+
'xgen-IPA-100': {
27+
reason: 'test',
28+
},
29+
},
30+
'x-xgen-owner-team': 'apix',
31+
};
32+
33+
const objectWithIpa101Exception = {
34+
'x-xgen-IPA-exception': {
35+
'xgen-IPA-101': {
36+
reason: 'test',
37+
},
38+
},
39+
};
40+
41+
const objectWithIpa100And101Exception = {
42+
'x-xgen-IPA-exception': {
43+
'xgen-IPA-101': {
44+
reason: 'test',
45+
},
46+
'xgen-IPA-100': {
47+
reason: 'test',
48+
},
49+
},
50+
};
51+
52+
describe('tools/spectral/ipa/rulesets/functions/utils/exceptions.js', () => {
53+
describe('hasException', () => {
54+
it('returns true if object has exception matching the rule name', () => {
55+
expect(hasException(objectWithIpa100Exception, TEST_RULE_NAME_100)).toBe(true);
56+
expect(hasException(objectWithIpa100ExceptionAndOwnerExtension, TEST_RULE_NAME_100)).toBe(true);
57+
expect(hasException(objectWithIpa100And101Exception, TEST_RULE_NAME_100)).toBe(true);
58+
});
59+
it('returns false if object does not have exception matching the rule name', () => {
60+
expect(hasException({}, TEST_RULE_NAME_100)).toBe(false);
61+
expect(hasException(objectWithIpa101Exception, TEST_RULE_NAME_100)).toBe(false);
62+
});
63+
it('returns false if object has nested exception matching the rule name', () => {
64+
expect(hasException(objectWithNestedIpa100Exception, TEST_RULE_NAME_100)).toBe(false);
65+
});
66+
});
67+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
extends:
22
- ./rulesets/IPA-102.yaml
33
- ./rulesets/IPA-104.yaml
4+
- ./rulesets/IPA-005.yaml
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# IPA-5: Documenting Exceptions to IPAs
2+
# http://go/ipa/5
3+
4+
functions:
5+
- exceptionExtensionFormat
6+
7+
rules:
8+
xgen-IPA-005-exception-extension-format:
9+
description: 'IPA exception extensions must follow the correct format. http://go/ipa/5'
10+
message: '{{error}} http://go/ipa/5'
11+
severity: warn
12+
given: '$..x-xgen-IPA-exception'
13+
then:
14+
function: 'exceptionExtensionFormat'

tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { isPathParam } from './utils/pathUtils.js';
2+
import { hasException } from './utils/exceptions';
23

4+
const RULE_NAME = 'xgen-IPA-102-path-alternate-resource-name-path-param';
35
const ERROR_MESSAGE = 'API paths must alternate between resource name and path params.';
46
const ERROR_RESULT = [{ message: ERROR_MESSAGE }];
57
const AUTH_PREFIX = '/api/atlas/v2';
68
const UNAUTH_PREFIX = '/api/atlas/v2/unauth';
79

810
const getPrefix = (path) => {
9-
if (path.includes(UNAUTH_PREFIX)) return UNAUTH_PREFIX;
10-
if (path.includes(AUTH_PREFIX)) return AUTH_PREFIX;
11+
if (path.includes(UNAUTH_PREFIX)) {
12+
return UNAUTH_PREFIX;
13+
}
14+
if (path.includes(AUTH_PREFIX)) {
15+
return AUTH_PREFIX;
16+
}
1117
return null;
1218
};
1319

@@ -18,9 +24,16 @@ const validatePathStructure = (elements) => {
1824
});
1925
};
2026

21-
export default (input) => {
27+
export default (input, _, { documentInventory }) => {
28+
const oas = documentInventory.resolved;
29+
if (hasException(oas.paths[input], RULE_NAME)) {
30+
return;
31+
}
32+
2233
const prefix = getPrefix(input);
23-
if (!prefix) return;
34+
if (!prefix) {
35+
return;
36+
}
2437

2538
let suffixWithLeadingSlash = input.slice(prefix.length);
2639
if (suffixWithLeadingSlash.length === 0) {

tools/spectral/ipa/rulesets/functions/eachResourceHasGetMethod.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
isSingletonResource,
77
getResourcePaths,
88
} from './utils/resourceEvaluation.js';
9+
import { hasException } from './utils/exceptions';
910

11+
const RULE_NAME = 'xgen-IPA-104-resource-has-GET';
1012
const ERROR_MESSAGE = 'APIs must provide a get method for resources.';
1113

1214
export default (input, _, { documentInventory }) => {
@@ -15,6 +17,11 @@ export default (input, _, { documentInventory }) => {
1517
}
1618

1719
const oas = documentInventory.resolved;
20+
21+
if (hasException(oas.paths[input], RULE_NAME)) {
22+
return;
23+
}
24+
1825
const resourcePaths = getResourcePaths(input, Object.keys(oas.paths));
1926

2027
if (isSingletonResource(resourcePaths)) {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const ERROR_MESSAGE = 'IPA exceptions must have a valid rule name and a reason.';
2+
const RULE_NAME_PREFIX = 'xgen-IPA-';
3+
const REASON_KEY = 'reason';
4+
5+
// Note: This rule does not allow exceptions
6+
export default (input, _, { path }) => {
7+
const exemptedRules = Object.keys(input);
8+
const errors = [];
9+
10+
exemptedRules.forEach((ruleName) => {
11+
const exception = input[ruleName];
12+
if (!isValidException(ruleName, exception)) {
13+
errors.push({
14+
path: path.concat([ruleName]),
15+
message: ERROR_MESSAGE,
16+
});
17+
}
18+
});
19+
20+
return errors;
21+
};
22+
23+
function isValidException(ruleName, exception) {
24+
const exceptionObjectKeys = Object.keys(exception);
25+
return (
26+
ruleName.startsWith(RULE_NAME_PREFIX) &&
27+
exceptionObjectKeys.length === 1 &&
28+
exceptionObjectKeys.includes(REASON_KEY) &&
29+
exception[REASON_KEY] !== ''
30+
);
31+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const EXCEPTION_EXTENSION = 'x-xgen-IPA-exception';
2+
3+
/**
4+
* Checks if the object has an exception extension "x-xgen-IPA-exception"
5+
*
6+
* @param object the object to evaluate
7+
* @param ruleName the name of the exempted rule
8+
* @returns {boolean} true if the object has an exception named ruleName, otherwise false
9+
*/
10+
export function hasException(object, ruleName) {
11+
if (object[EXCEPTION_EXTENSION]) {
12+
return Object.keys(object[EXCEPTION_EXTENSION]).includes(ruleName);
13+
}
14+
return false;
15+
}

0 commit comments

Comments
 (0)