Skip to content

Commit 89b16b0

Browse files
CLOUDP-285964: Adds IPA rule 104 - Resource has get (#301)
1 parent c875bcd commit 89b16b0

File tree

11 files changed

+8284
-708
lines changed

11 files changed

+8284
-708
lines changed

babel.config.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"presets": ["@babel/preset-env"]
3+
}

eslint.config.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import globals from 'globals';
22
import pluginJs from '@eslint/js';
3+
import pluginJest from 'eslint-plugin-jest';
4+
import jest from 'eslint-plugin-jest';
35

46
/** @type {import('eslint').Linter.Config[]} */
57
export default [
6-
{ languageOptions: { globals: globals.browser } },
8+
{
9+
plugins: { jest: pluginJest },
10+
languageOptions: { globals: globals.node },
11+
},
712
pluginJs.configs.recommended,
813
{
914
languageOptions: {
@@ -14,4 +19,8 @@ export default [
1419
{
1520
ignores: ['node-modules'],
1621
},
22+
{
23+
files: ['**/*.test.js'],
24+
...jest.configs['flat/recommended'],
25+
},
1726
];

package-lock.json

Lines changed: 7876 additions & 706 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,33 @@
44
"scripts": {
55
"format": "npx prettier . --write",
66
"format-check": "npx prettier . --check",
7-
"lint-js": "npx eslint **/*.js"
7+
"lint-js": "npx eslint **/*.js",
8+
"ipa-validation": "spectral lint ./openapi/v2.yaml --ruleset=./tools/spectral/ipa/ipa-spectral.yaml",
9+
"test": "jest"
10+
},
11+
"jest": {
12+
"transform": {
13+
"^.+\\.[t|j]sx?$": "babel-jest"
14+
},
15+
"testPathIgnorePatterns": [
16+
"__helpers__"
17+
]
818
},
919
"dependencies": {
20+
"@stoplight/spectral-cli": "^6.14.2",
21+
"@stoplight/spectral-core": "^1.19.4",
22+
"@stoplight/spectral-ref-resolver": "^1.0.5",
23+
"@stoplight/spectral-ruleset-bundler": "^1.6.1",
24+
"eslint-plugin-jest": "^28.9.0",
1025
"openapi-to-postmanv2": "4.24.0"
1126
},
1227
"devDependencies": {
28+
"@babel/preset-env": "^7.26.0",
1329
"@eslint/js": "^9.16.0",
30+
"@jest/globals": "^29.7.0",
1431
"eslint": "^9.16.0",
1532
"globals": "^15.13.0",
33+
"jest": "^29.7.0",
1634
"prettier": "3.4.2"
1735
}
1836
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import { describe, expect, it } from '@jest/globals';
4+
import { Spectral, Document } from '@stoplight/spectral-core';
5+
import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver';
6+
import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader';
7+
8+
const rulesetPath = path.join(__dirname, '../..', 'ipa-spectral.yaml');
9+
10+
export default (ruleName, tests) => {
11+
describe(`Rule ${ruleName}`, () => {
12+
for (const testCase of tests) {
13+
it.concurrent(testCase.name, async () => {
14+
const s = await createSpectral();
15+
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);
19+
20+
expect(errors.length).toEqual(testCase.errors.length);
21+
22+
errors.forEach((error, index) => {
23+
expect(error.code).toEqual(testCase.errors[index].code);
24+
expect(error.message).toEqual(testCase.errors[index].message);
25+
expect(error.path).toEqual(testCase.errors[index].path);
26+
});
27+
});
28+
}
29+
});
30+
};
31+
32+
async function createSpectral() {
33+
const s = new Spectral({ resolver: httpAndFileResolver });
34+
s.setRuleset(await bundleAndLoadRuleset(rulesetPath, { fs, fetch }));
35+
return s;
36+
}
37+
38+
function getErrorsForRule(errors, rule) {
39+
return errors.filter((e) => e.code === rule);
40+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import testRule from './__helpers__/testRule';
2+
import { DiagnosticSeverity } from '@stoplight/types';
3+
4+
testRule('xgen-IPA-104-resource-has-GET', [
5+
{
6+
name: 'valid methods',
7+
document: {
8+
paths: {
9+
'/standard': {
10+
post: {},
11+
get: {},
12+
},
13+
'/standard/{exampleId}': {
14+
get: {},
15+
patch: {},
16+
delete: {},
17+
},
18+
'/standard/{exampleId}/nested': {
19+
post: {},
20+
get: {},
21+
},
22+
'/standard/{exampleId}/nested/{exampleId}': {
23+
get: {},
24+
patch: {},
25+
delete: {},
26+
},
27+
'/standard/{exampleId}/nestedSingleton': {
28+
get: {},
29+
patch: {},
30+
},
31+
'/custom': {
32+
post: {},
33+
get: {},
34+
},
35+
'/custom/{exampleId}': {
36+
get: {},
37+
patch: {},
38+
delete: {},
39+
},
40+
'/custom/{exampleId}:method': {
41+
post: {},
42+
},
43+
'/custom:method': {
44+
post: {},
45+
},
46+
'/singleton': {
47+
get: {},
48+
},
49+
},
50+
},
51+
errors: [],
52+
},
53+
{
54+
name: 'invalid methods',
55+
document: {
56+
paths: {
57+
'/standard': {
58+
post: {},
59+
get: {},
60+
},
61+
'/standard/{exampleId}': {
62+
patch: {},
63+
delete: {},
64+
},
65+
'/standard/{exampleId}/nested': {
66+
post: {},
67+
get: {},
68+
},
69+
'/standard/{exampleId}/nested/{exampleId}': {
70+
patch: {},
71+
delete: {},
72+
},
73+
'/standard/{exampleId}/nestedSingleton': {
74+
patch: {},
75+
},
76+
'/custom': {
77+
post: {},
78+
get: {},
79+
},
80+
'/custom/{exampleId}': {
81+
patch: {},
82+
delete: {},
83+
},
84+
'/custom/{exampleId}:method': {
85+
post: {},
86+
},
87+
'/custom:method': {
88+
post: {},
89+
},
90+
'/singleton': {
91+
patch: {},
92+
},
93+
},
94+
},
95+
errors: [
96+
{
97+
code: 'xgen-IPA-104-resource-has-GET',
98+
message: 'APIs must provide a get method for resources. http://go/ipa/117',
99+
path: ['paths', '/standard'],
100+
severity: DiagnosticSeverity.Warning,
101+
},
102+
{
103+
code: 'xgen-IPA-104-resource-has-GET',
104+
message: 'APIs must provide a get method for resources. http://go/ipa/117',
105+
path: ['paths', '/standard/{exampleId}/nested'],
106+
severity: DiagnosticSeverity.Warning,
107+
},
108+
{
109+
code: 'xgen-IPA-104-resource-has-GET',
110+
message: 'APIs must provide a get method for resources. http://go/ipa/117',
111+
path: ['paths', '/standard/{exampleId}/nestedSingleton'],
112+
severity: DiagnosticSeverity.Warning,
113+
},
114+
{
115+
code: 'xgen-IPA-104-resource-has-GET',
116+
message: 'APIs must provide a get method for resources. http://go/ipa/117',
117+
path: ['paths', '/custom'],
118+
severity: DiagnosticSeverity.Warning,
119+
},
120+
{
121+
code: 'xgen-IPA-104-resource-has-GET',
122+
message: 'APIs must provide a get method for resources. http://go/ipa/117',
123+
path: ['paths', '/singleton'],
124+
severity: DiagnosticSeverity.Warning,
125+
},
126+
],
127+
},
128+
]);
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
import {
3+
getResourcePaths,
4+
isSingletonResource,
5+
isStandardResource,
6+
} from '../../rulesets/functions/utils/resourceEvaluation';
7+
8+
const standardResourcePaths = ['/standard', '/standard/{id}'];
9+
10+
const nestedStandardResourcePaths = ['/standard/{exampleId}/nested', '/standard/{exampleId}/nested/{exampleId}'];
11+
12+
const standardResourceWithCustomPaths = ['/customStandard', '/customStandard/{id}', '/customStandard:method'];
13+
14+
const singletonResourcePaths = ['/singleton'];
15+
16+
const singletonResourceWithCustomPaths = ['/customSingleton', '/customSingleton:method'];
17+
18+
const nestedSingletonResourcePaths = ['/standard/{exampleId}/nestedSingleton'];
19+
20+
describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () => {
21+
describe('getResourcePaths', () => {
22+
it('returns the paths for a resource based on parent path', () => {
23+
const allPaths = standardResourcePaths.concat(
24+
nestedStandardResourcePaths,
25+
standardResourceWithCustomPaths,
26+
singletonResourcePaths,
27+
singletonResourceWithCustomPaths,
28+
nestedSingletonResourcePaths
29+
);
30+
expect(getResourcePaths('/standard', allPaths)).toEqual(standardResourcePaths);
31+
expect(getResourcePaths('/standard/{exampleId}/nested', allPaths)).toEqual(nestedStandardResourcePaths);
32+
expect(getResourcePaths('/customStandard', allPaths)).toEqual(standardResourceWithCustomPaths);
33+
expect(getResourcePaths('/singleton', allPaths)).toEqual(singletonResourcePaths);
34+
expect(getResourcePaths('/customSingleton', allPaths)).toEqual(singletonResourceWithCustomPaths);
35+
expect(getResourcePaths('/standard/{exampleId}/nestedSingleton', allPaths)).toEqual(nestedSingletonResourcePaths);
36+
});
37+
});
38+
describe('isStandardResource', () => {
39+
it('returns true for a standard resource', () => {
40+
expect(isStandardResource(standardResourcePaths)).toBe(true);
41+
});
42+
43+
it('returns true for a standard resource with custom methods', () => {
44+
expect(isStandardResource(standardResourceWithCustomPaths)).toBe(true);
45+
});
46+
47+
it('returns true for a nested standard resource', () => {
48+
expect(isStandardResource(nestedStandardResourcePaths)).toBe(true);
49+
});
50+
51+
it('returns false for a singleton resource', () => {
52+
expect(isStandardResource(singletonResourcePaths)).toBe(false);
53+
});
54+
55+
it('returns false for a singleton resource with custom methods', () => {
56+
expect(isStandardResource(singletonResourceWithCustomPaths)).toBe(false);
57+
});
58+
59+
it('returns false for a nested singleton resource', () => {
60+
expect(isStandardResource(nestedSingletonResourcePaths)).toBe(false);
61+
});
62+
});
63+
describe('isSingletonResource', () => {
64+
it('returns true for a singleton resource', () => {
65+
expect(isSingletonResource(singletonResourcePaths)).toBe(true);
66+
});
67+
68+
it('returns true for a singleton resource with custom methods', () => {
69+
expect(isSingletonResource(singletonResourceWithCustomPaths)).toBe(true);
70+
});
71+
72+
it('returns true for a nested singleton resource', () => {
73+
expect(isSingletonResource(nestedSingletonResourcePaths)).toBe(true);
74+
});
75+
76+
it('returns false for a standard resource', () => {
77+
expect(isSingletonResource(standardResourcePaths)).toBe(false);
78+
});
79+
80+
it('returns false for a standard resource with custom methods', () => {
81+
expect(isSingletonResource(standardResourceWithCustomPaths)).toBe(false);
82+
});
83+
84+
it('returns false for a nested standard resource', () => {
85+
expect(isSingletonResource(nestedStandardResourcePaths)).toBe(false);
86+
});
87+
});
88+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
extends:
2+
- ./rulesets/IPA-104.yaml
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# IPA-104: Get
2+
# http://go/ipa/104
3+
4+
functions:
5+
- eachResourceHasGetMethod
6+
7+
rules:
8+
xgen-IPA-104-resource-has-GET:
9+
description: "APIs must provide a get method for resources. http://go/ipa/104"
10+
message: "{{error}} http://go/ipa/117"
11+
severity: warn
12+
given: "$.paths"
13+
then:
14+
field: "@key"
15+
function: "eachResourceHasGetMethod"
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
hasGetMethod,
3+
isChild,
4+
isCustomMethod,
5+
isStandardResource,
6+
isSingletonResource,
7+
getResourcePaths,
8+
} from './utils/resourceEvaluation.js';
9+
10+
const ERROR_MESSAGE = 'APIs must provide a get method for resources.';
11+
12+
export default (input, _, { documentInventory }) => {
13+
if (isChild(input) || isCustomMethod(input)) {
14+
return;
15+
}
16+
17+
const oas = documentInventory.resolved;
18+
const resourcePaths = getResourcePaths(input, Object.keys(oas.paths));
19+
20+
if (isSingletonResource(resourcePaths)) {
21+
if (!hasGetMethod(oas.paths[resourcePaths[0]])) {
22+
return [
23+
{
24+
message: ERROR_MESSAGE,
25+
},
26+
];
27+
}
28+
} else if (isStandardResource(resourcePaths)) {
29+
if (!hasGetMethod(oas.paths[resourcePaths[1]])) {
30+
return [
31+
{
32+
message: ERROR_MESSAGE,
33+
},
34+
];
35+
}
36+
}
37+
};

0 commit comments

Comments
 (0)