Skip to content

Commit bd98889

Browse files
CLOUDP-287250: Add rule IPA-113 Singleton must not have ID
1 parent ef16431 commit bd98889

File tree

8 files changed

+314
-11
lines changed

8 files changed

+314
-11
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@babel/preset-env": "^7.26.0",
3030
"@eslint/js": "^9.16.0",
3131
"@jest/globals": "^29.7.0",
32+
"@stoplight/types": "^14.1.1",
3233
"eslint": "^9.17.0",
3334
"eslint-plugin-require-extensions": "^0.1.3",
3435
"globals": "^15.14.0",

tools/spectral/ipa/__tests__/__helpers__/testRule.js

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,13 @@ import { Spectral, Document } from '@stoplight/spectral-core';
55
import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver';
66
import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader';
77

8-
const rulesetPath = path.join(__dirname, '../..', 'ipa-spectral.yaml');
9-
108
export default (ruleName, tests) => {
119
describe(`Rule ${ruleName}`, () => {
1210
for (const testCase of tests) {
1311
it.concurrent(testCase.name, async () => {
14-
const s = await createSpectral();
12+
const s = await createSpectral(ruleName);
1513
const doc = testCase.document instanceof Document ? testCase.document : JSON.stringify(testCase.document);
16-
const allErrors = await s.run(doc);
17-
18-
const errors = getErrorsForRule(allErrors, ruleName);
14+
const errors = await s.run(doc);
1915

2016
expect(errors.length).toEqual(testCase.errors.length);
2117

@@ -29,12 +25,23 @@ export default (ruleName, tests) => {
2925
});
3026
};
3127

32-
async function createSpectral() {
28+
async function createSpectral(ruleName) {
29+
const rulesetPath = path.join(__dirname, '../../rulesets', ruleName.slice(5, 12) + '.yaml');
3330
const s = new Spectral({ resolver: httpAndFileResolver });
34-
s.setRuleset(await bundleAndLoadRuleset(rulesetPath, { fs, fetch }));
31+
const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })).toJSON();
32+
s.setRuleset(getRulesetForRule(ruleName, ruleset));
3533
return s;
3634
}
3735

38-
function getErrorsForRule(errors, rule) {
39-
return errors.filter((e) => e.code === rule);
36+
/**
37+
* Takes the passed ruleset and returns a ruleset with only the specified rule.
38+
*
39+
* @param ruleName the name of the rule
40+
* @param ruleset the ruleset containing the rule by ruleName and optionally other rules
41+
* @returns {Object} a ruleset with only the rule with name ruleName
42+
*/
43+
function getRulesetForRule(ruleName, ruleset) {
44+
const modifiedRuleset = { rules: {} };
45+
modifiedRuleset.rules[ruleName] = ruleset.rules[ruleName].definition;
46+
return modifiedRuleset;
4047
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import testRule from './__helpers__/testRule';
2+
import { DiagnosticSeverity } from '@stoplight/types';
3+
4+
testRule('xgen-IPA-113-singleton-must-not-have-id', [
5+
{
6+
name: 'valid resources',
7+
document: {
8+
paths: {
9+
'/standard': {
10+
post: {},
11+
get: {
12+
responses: {
13+
200: {
14+
content: {
15+
version1: {
16+
schema: {
17+
properties: {
18+
id: {},
19+
someProperty: {},
20+
},
21+
type: 'object',
22+
},
23+
},
24+
},
25+
},
26+
},
27+
},
28+
},
29+
'/standard/{exampleId}': {
30+
get: {
31+
responses: {
32+
200: {
33+
content: {
34+
version1: {
35+
schema: {
36+
properties: {
37+
id: {},
38+
someProperty: {},
39+
},
40+
type: 'object',
41+
},
42+
},
43+
},
44+
},
45+
},
46+
},
47+
patch: {},
48+
delete: {},
49+
},
50+
'/singleton1': {
51+
get: {
52+
responses: {
53+
200: {
54+
content: {
55+
version1: {
56+
schema: {
57+
properties: {
58+
someProperty: {},
59+
},
60+
type: 'object',
61+
},
62+
},
63+
},
64+
},
65+
},
66+
},
67+
},
68+
'/singleton2': {
69+
get: {
70+
responses: {
71+
200: {
72+
content: {
73+
version1: {
74+
schema: {
75+
properties: {
76+
someId: {},
77+
someProperty: {},
78+
},
79+
type: 'object',
80+
},
81+
},
82+
},
83+
},
84+
},
85+
},
86+
},
87+
},
88+
},
89+
errors: [],
90+
},
91+
{
92+
name: 'invalid resources',
93+
document: {
94+
paths: {
95+
'/singleton1': {
96+
get: {
97+
responses: {
98+
200: {
99+
content: {
100+
version1: {
101+
schema: {
102+
properties: {
103+
id: {},
104+
someProperty: {},
105+
},
106+
type: 'object',
107+
},
108+
},
109+
},
110+
},
111+
},
112+
},
113+
},
114+
'/singleton2': {
115+
get: {
116+
responses: {
117+
200: {
118+
content: {
119+
version1: {
120+
schema: {
121+
properties: {
122+
_id: {},
123+
someProperty: {},
124+
},
125+
type: 'object',
126+
},
127+
},
128+
},
129+
},
130+
},
131+
},
132+
},
133+
'/singleton3': {
134+
get: {
135+
responses: {
136+
200: {
137+
content: {
138+
version1: {
139+
schema: {
140+
properties: {
141+
someId: {},
142+
someProperty: {},
143+
},
144+
type: 'object',
145+
},
146+
},
147+
version2: {
148+
schema: {
149+
properties: {
150+
id: {},
151+
someProperty: {},
152+
},
153+
type: 'object',
154+
},
155+
},
156+
},
157+
},
158+
},
159+
},
160+
},
161+
},
162+
},
163+
errors: [
164+
{
165+
code: 'xgen-IPA-113-singleton-must-not-have-id',
166+
message: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113',
167+
path: ['paths', '/singleton1'],
168+
severity: DiagnosticSeverity.Warning,
169+
},
170+
{
171+
code: 'xgen-IPA-113-singleton-must-not-have-id',
172+
message: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113',
173+
path: ['paths', '/singleton2'],
174+
severity: DiagnosticSeverity.Warning,
175+
},
176+
{
177+
code: 'xgen-IPA-113-singleton-must-not-have-id',
178+
message: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113',
179+
path: ['paths', '/singleton3'],
180+
severity: DiagnosticSeverity.Warning,
181+
},
182+
],
183+
},
184+
{
185+
name: 'invalid resources with exceptions',
186+
document: {
187+
paths: {
188+
'/singleton1': {
189+
'x-xgen-IPA-exception': {
190+
'xgen-IPA-113-singleton-must-not-have-id': 'reason',
191+
},
192+
get: {
193+
responses: {
194+
200: {
195+
content: {
196+
version1: {
197+
schema: {
198+
properties: {
199+
id: {},
200+
someProperty: {},
201+
},
202+
type: 'object',
203+
},
204+
},
205+
},
206+
},
207+
},
208+
},
209+
},
210+
},
211+
},
212+
errors: [],
213+
},
214+
]);
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
extends:
2-
- ./rulesets/IPA-005.yaml
32
- ./rulesets/IPA-102.yaml
43
- ./rulesets/IPA-104.yaml
4+
- ./rulesets/IPA-005.yaml
55
- ./rulesets/IPA-109.yaml
6+
- ./rulesets/IPA-113.yaml
67
- ./rulesets/IPA-123.yaml
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# IPA-113: Singleton Resources
2+
# http://go/ipa/113
3+
4+
functions:
5+
- singletonHasNoId
6+
7+
rules:
8+
xgen-IPA-113-singleton-must-not-have-id:
9+
description: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113'
10+
message: '{{error}} http://go/ipa/113'
11+
severity: warn
12+
given: '$.paths[*]'
13+
then:
14+
function: 'singletonHasNoId'
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
getResourcePaths,
3+
hasGetMethod,
4+
isChild,
5+
isCustomMethod,
6+
isSingletonResource,
7+
} from './utils/resourceEvaluation.js';
8+
import { hasException } from './utils/exceptions.js';
9+
import { getAllSuccessfulGetResponseSchemas } from './utils/methodUtils.js';
10+
11+
const RULE_NAME = 'xgen-IPA-113-singleton-must-not-have-id';
12+
const ERROR_MESSAGE = 'Singleton resources must not have a user-provided or system-generated ID.';
13+
14+
export default (input, opts, { path, documentInventory }) => {
15+
const resourcePath = path[1];
16+
17+
if (isCustomMethod(resourcePath) || isChild(resourcePath)) {
18+
return;
19+
}
20+
21+
if (hasException(input, RULE_NAME)) {
22+
return;
23+
}
24+
25+
const oas = documentInventory.resolved;
26+
const resourcePaths = getResourcePaths(resourcePath, Object.keys(oas.paths));
27+
28+
if (isSingletonResource(resourcePaths) && hasGetMethod(input)) {
29+
const resourceSchemas = getAllSuccessfulGetResponseSchemas(input);
30+
if (resourceSchemas.some((schema) => schemaHasIdProperty(schema))) {
31+
return [
32+
{
33+
message: ERROR_MESSAGE,
34+
},
35+
];
36+
}
37+
}
38+
};
39+
40+
function schemaHasIdProperty(schema) {
41+
if (Object.keys(schema).includes('properties')) {
42+
const propertyNames = Object.keys(schema['properties']);
43+
return propertyNames.includes('id') || propertyNames.includes('_id');
44+
}
45+
return false;
46+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Returns a list of all successful response schemas for the 'get' method of the passed resource, i.e. for any 2xx response.
3+
*
4+
* @param {object} pathObject the object for the path
5+
* @returns {Object[]} all 2xx 'get' response schemas
6+
*/
7+
export function getAllSuccessfulGetResponseSchemas(pathObject) {
8+
const responses = pathObject['get']['responses'];
9+
const successfulResponseKey = Object.keys(responses).filter((k) => k.startsWith('2'))[0];
10+
const responseContent = responses[successfulResponseKey]['content'];
11+
const result = [];
12+
Object.keys(responseContent).forEach((k) => {
13+
const schema = responseContent[k]['schema'];
14+
if (schema) {
15+
result.push(schema);
16+
}
17+
});
18+
return result;
19+
}

0 commit comments

Comments
 (0)