diff --git a/package-lock.json b/package-lock.json index 3bd5bdc3c2..53dc6ae423 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@babel/preset-env": "^7.26.0", "@eslint/js": "^9.16.0", "@jest/globals": "^29.7.0", + "@stoplight/types": "^14.1.1", "eslint": "^9.17.0", "eslint-plugin-require-extensions": "^0.1.3", "globals": "^15.14.0", diff --git a/package.json b/package.json index b7ae587132..fd649f3d47 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@babel/preset-env": "^7.26.0", "@eslint/js": "^9.16.0", "@jest/globals": "^29.7.0", + "@stoplight/types": "^14.1.1", "eslint": "^9.17.0", "eslint-plugin-require-extensions": "^0.1.3", "globals": "^15.14.0", diff --git a/tools/spectral/ipa/__tests__/__helpers__/testRule.js b/tools/spectral/ipa/__tests__/__helpers__/testRule.js index 779a6245cb..622a15b9db 100644 --- a/tools/spectral/ipa/__tests__/__helpers__/testRule.js +++ b/tools/spectral/ipa/__tests__/__helpers__/testRule.js @@ -5,17 +5,13 @@ import { Spectral, Document } from '@stoplight/spectral-core'; import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver'; import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; -const rulesetPath = path.join(__dirname, '../..', 'ipa-spectral.yaml'); - export default (ruleName, tests) => { describe(`Rule ${ruleName}`, () => { for (const testCase of tests) { it.concurrent(testCase.name, async () => { - const s = await createSpectral(); + const s = await createSpectral(ruleName); const doc = testCase.document instanceof Document ? testCase.document : JSON.stringify(testCase.document); - const allErrors = await s.run(doc); - - const errors = getErrorsForRule(allErrors, ruleName); + const errors = await s.run(doc); expect(errors.length).toEqual(testCase.errors.length); @@ -29,12 +25,23 @@ export default (ruleName, tests) => { }); }; -async function createSpectral() { +async function createSpectral(ruleName) { + const rulesetPath = path.join(__dirname, '../../rulesets', ruleName.slice(5, 12) + '.yaml'); const s = new Spectral({ resolver: httpAndFileResolver }); - s.setRuleset(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })); + const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })).toJSON(); + s.setRuleset(getRulesetForRule(ruleName, ruleset)); return s; } -function getErrorsForRule(errors, rule) { - return errors.filter((e) => e.code === rule); +/** + * Takes the passed ruleset and returns a ruleset with only the specified rule. + * + * @param ruleName the name of the rule + * @param ruleset the ruleset containing the rule by ruleName and optionally other rules + * @returns {Object} a ruleset with only the rule with name ruleName + */ +function getRulesetForRule(ruleName, ruleset) { + const modifiedRuleset = { rules: {} }; + modifiedRuleset.rules[ruleName] = ruleset.rules[ruleName].definition; + return modifiedRuleset; } diff --git a/tools/spectral/ipa/__tests__/singletonHasNoId.test.js b/tools/spectral/ipa/__tests__/singletonHasNoId.test.js new file mode 100644 index 0000000000..dc448a8e05 --- /dev/null +++ b/tools/spectral/ipa/__tests__/singletonHasNoId.test.js @@ -0,0 +1,214 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-113-singleton-must-not-have-id', [ + { + name: 'valid resources', + document: { + paths: { + '/standard': { + post: {}, + get: { + responses: { + 200: { + content: { + version1: { + schema: { + properties: { + id: {}, + someProperty: {}, + }, + type: 'object', + }, + }, + }, + }, + }, + }, + }, + '/standard/{exampleId}': { + get: { + responses: { + 200: { + content: { + version1: { + schema: { + properties: { + id: {}, + someProperty: {}, + }, + type: 'object', + }, + }, + }, + }, + }, + }, + patch: {}, + delete: {}, + }, + '/singleton1': { + get: { + responses: { + 200: { + content: { + version1: { + schema: { + properties: { + someProperty: {}, + }, + type: 'object', + }, + }, + }, + }, + }, + }, + }, + '/singleton2': { + get: { + responses: { + 200: { + content: { + version1: { + schema: { + properties: { + someId: {}, + someProperty: {}, + }, + type: 'object', + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid resources', + document: { + paths: { + '/singleton1': { + get: { + responses: { + 200: { + content: { + version1: { + schema: { + properties: { + id: {}, + someProperty: {}, + }, + type: 'object', + }, + }, + }, + }, + }, + }, + }, + '/singleton2': { + get: { + responses: { + 200: { + content: { + version1: { + schema: { + properties: { + _id: {}, + someProperty: {}, + }, + type: 'object', + }, + }, + }, + }, + }, + }, + }, + '/singleton3': { + get: { + responses: { + 200: { + content: { + version1: { + schema: { + properties: { + someId: {}, + someProperty: {}, + }, + type: 'object', + }, + }, + version2: { + schema: { + properties: { + id: {}, + someProperty: {}, + }, + type: 'object', + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-113-singleton-must-not-have-id', + message: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113', + path: ['paths', '/singleton1'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-113-singleton-must-not-have-id', + message: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113', + path: ['paths', '/singleton2'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-113-singleton-must-not-have-id', + message: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113', + path: ['paths', '/singleton3'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid resources with exceptions', + document: { + paths: { + '/singleton1': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-113-singleton-must-not-have-id': 'reason', + }, + get: { + responses: { + 200: { + content: { + version1: { + schema: { + properties: { + id: {}, + someProperty: {}, + }, + type: 'object', + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index 204b0e2db0..955bc7cb96 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -1,6 +1,7 @@ extends: - - ./rulesets/IPA-005.yaml - ./rulesets/IPA-102.yaml - ./rulesets/IPA-104.yaml + - ./rulesets/IPA-005.yaml - ./rulesets/IPA-109.yaml + - ./rulesets/IPA-113.yaml - ./rulesets/IPA-123.yaml diff --git a/tools/spectral/ipa/rulesets/IPA-113.yaml b/tools/spectral/ipa/rulesets/IPA-113.yaml new file mode 100644 index 0000000000..fe6de74bc9 --- /dev/null +++ b/tools/spectral/ipa/rulesets/IPA-113.yaml @@ -0,0 +1,14 @@ +# IPA-113: Singleton Resources +# http://go/ipa/113 + +functions: + - singletonHasNoId + +rules: + xgen-IPA-113-singleton-must-not-have-id: + description: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113' + message: '{{error}} http://go/ipa/113' + severity: warn + given: '$.paths[*]' + then: + function: 'singletonHasNoId' diff --git a/tools/spectral/ipa/rulesets/functions/singletonHasNoId.js b/tools/spectral/ipa/rulesets/functions/singletonHasNoId.js new file mode 100644 index 0000000000..038514c95a --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/singletonHasNoId.js @@ -0,0 +1,46 @@ +import { + getResourcePaths, + hasGetMethod, + isChild, + isCustomMethod, + isSingletonResource, +} from './utils/resourceEvaluation.js'; +import { hasException } from './utils/exceptions.js'; +import { getAllSuccessfulGetResponseSchemas } from './utils/methodUtils.js'; + +const RULE_NAME = 'xgen-IPA-113-singleton-must-not-have-id'; +const ERROR_MESSAGE = 'Singleton resources must not have a user-provided or system-generated ID.'; + +export default (input, opts, { path, documentInventory }) => { + const resourcePath = path[1]; + + if (isCustomMethod(resourcePath) || isChild(resourcePath)) { + return; + } + + if (hasException(input, RULE_NAME)) { + return; + } + + const oas = documentInventory.resolved; + const resourcePaths = getResourcePaths(resourcePath, Object.keys(oas.paths)); + + if (isSingletonResource(resourcePaths) && hasGetMethod(input)) { + const resourceSchemas = getAllSuccessfulGetResponseSchemas(input); + if (resourceSchemas.some((schema) => schemaHasIdProperty(schema))) { + return [ + { + message: ERROR_MESSAGE, + }, + ]; + } + } +}; + +function schemaHasIdProperty(schema) { + if (Object.keys(schema).includes('properties')) { + const propertyNames = Object.keys(schema['properties']); + return propertyNames.includes('id') || propertyNames.includes('_id'); + } + return false; +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js b/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js new file mode 100644 index 0000000000..c13315e882 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js @@ -0,0 +1,19 @@ +/** + * Returns a list of all successful response schemas for the 'get' method of the passed resource, i.e. for any 2xx response. + * + * @param {object} pathObject the object for the path + * @returns {Object[]} all 2xx 'get' response schemas + */ +export function getAllSuccessfulGetResponseSchemas(pathObject) { + const responses = pathObject['get']['responses']; + const successfulResponseKey = Object.keys(responses).filter((k) => k.startsWith('2'))[0]; + const responseContent = responses[successfulResponseKey]['content']; + const result = []; + Object.keys(responseContent).forEach((k) => { + const schema = responseContent[k]['schema']; + if (schema) { + result.push(schema); + } + }); + return result; +}