diff --git a/tools/spectral/ipa/__tests__/utils/extensions.test.js b/tools/spectral/ipa/__tests__/utils/extensions.test.js index 7a20020237..6088ec499e 100644 --- a/tools/spectral/ipa/__tests__/utils/extensions.test.js +++ b/tools/spectral/ipa/__tests__/utils/extensions.test.js @@ -1,8 +1,9 @@ -import { describe, it, expect, toBe } from '@jest/globals'; +import { describe, it, expect } from '@jest/globals'; import { - hasMethodWithVerbOverride, hasCustomMethodOverride, hasMethodVerbOverride, + hasOperationIdOverride, + getOperationIdOverride, } from '../../rulesets/functions/utils/extensions'; const methodWithExtension = { @@ -29,20 +30,23 @@ const endpointWithNoMethodExtension = { exception: true, }; -describe('tools/spectral/ipa/rulesets/functions/utils/extensions.js', () => { - describe('hasMethodWithVerbOverride', () => { - it('returns true if endpoint has method with the extension', () => { - expect(hasMethodWithVerbOverride(endpointWithMethodExtension)).toBe(true); - }); - it('returns false if object does not a method with the extension', () => { - expect(hasMethodWithVerbOverride(endpointWithNoMethodExtension)).toBe(false); - }); - }); -}); +const operationWithOperationIdOverride = { + operationId: 'operationId', + 'x-xgen-operation-id-override': 'customOperationId', +}; + +const operationWithEmptyOperationIdOverride = { + operationId: 'operationId', + 'x-xgen-operation-id-override': '', +}; + +const operationWithNoOperationIdOverride = { + operationId: 'operationId', +}; describe('tools/spectral/ipa/rulesets/functions/utils/extensions.js', () => { describe('hasCustomMethodOverride', () => { - it('returns true if the method has the extension with the cusotmMethod boolean set to true', () => { + it('returns true if the method has the extension with the customMethod boolean set to true', () => { expect(hasCustomMethodOverride(customMethod)).toBe(true); }); it('returns false if the method does not have the extension', () => { @@ -52,9 +56,7 @@ describe('tools/spectral/ipa/rulesets/functions/utils/extensions.js', () => { expect(hasCustomMethodOverride(methodWithExtension)).toBe(false); }); }); -}); -describe('tools/spectral/ipa/rulesets/functions/utils/extensions.js', () => { describe('hasMethodVerbOverride', () => { it('returns true if the method has the extension with the expected verb', () => { expect(hasMethodVerbOverride(methodWithExtension, 'get')).toBe(true); @@ -66,4 +68,28 @@ describe('tools/spectral/ipa/rulesets/functions/utils/extensions.js', () => { expect(hasMethodVerbOverride(methodWithExtension, 'put')).toBe(false); }); }); + + describe('hasOperationIdOverride', () => { + it('returns true if the method has the extension', () => { + expect(hasOperationIdOverride(operationWithOperationIdOverride)).toBe(true); + }); + it('returns true if the method has the extension but with an empty value', () => { + expect(hasOperationIdOverride(operationWithEmptyOperationIdOverride)).toBe(true); + }); + it('returns false if the method does not have the extension', () => { + expect(hasOperationIdOverride(operationWithNoOperationIdOverride)).toBe(false); + }); + }); + + describe('getOperationIdOverride', () => { + it('returns the value if the method has the extension', () => { + expect(getOperationIdOverride(operationWithOperationIdOverride)).toBe('customOperationId'); + }); + it('returns an empty value if the method has the extension with an empty value', () => { + expect(getOperationIdOverride(operationWithEmptyOperationIdOverride)).toBe(''); + }); + it('returns undefined if the method does not have the extension', () => { + expect(getOperationIdOverride(operationWithNoOperationIdOverride)).toBe(undefined); + }); + }); }); diff --git a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js index 7f1f92dafd..d297df8832 100644 --- a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js +++ b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js @@ -1,43 +1,79 @@ import { describe, expect, it } from '@jest/globals'; -import { generateOperationID } from '../../rulesets/functions/utils/operationIdGeneration'; +import { + generateOperationID, + numberOfWords, + shortenOperationId, +} from '../../rulesets/functions/utils/operationIdGeneration'; describe('tools/spectral/ipa/utils/operationIdGeneration.js', () => { - it('should singularize all nouns', () => { - expect(generateOperationID('create', '/groups/{groupId}/clusters')).toEqual('createGroupCluster'); - expect(generateOperationID('delete', '/groups/{groupId}/clusters/{clusterName}')).toEqual('deleteGroupCluster'); - expect(generateOperationID('get', '/groups/{groupId}/clusters/{clusterName}')).toEqual('getGroupCluster'); - expect(generateOperationID('update', '/groups/{groupId}/clusters/{clusterName}')).toEqual('updateGroupCluster'); - expect(generateOperationID('pause', '/groups/{groupId}/clusters/{clusterName}')).toEqual('pauseGroupCluster'); - }); + describe('generateOperationID', () => { + it('should singularize all nouns', () => { + expect(generateOperationID('create', '/groups/{groupId}/clusters')).toEqual('createGroupCluster'); + expect(generateOperationID('delete', '/groups/{groupId}/clusters/{clusterName}')).toEqual('deleteGroupCluster'); + expect(generateOperationID('get', '/groups/{groupId}/clusters/{clusterName}')).toEqual('getGroupCluster'); + expect(generateOperationID('update', '/groups/{groupId}/clusters/{clusterName}')).toEqual('updateGroupCluster'); + expect(generateOperationID('pause', '/groups/{groupId}/clusters/{clusterName}')).toEqual('pauseGroupCluster'); + }); + + it('should leave the final noun as is', () => { + expect(generateOperationID('list', '/groups/{groupId}/clusters')).toEqual('listGroupClusters'); + expect(generateOperationID('get', '/groups/{groupId}/settings')).toEqual('getGroupSettings'); + expect(generateOperationID('update', '/groups/{groupId}/settings')).toEqual('updateGroupSettings'); + expect(generateOperationID('search', '/groups/{groupId}/clusters')).toEqual('searchGroupClusters'); + expect( + generateOperationID( + 'get', + '/groups/{groupId}/clusters/{clusterName}/{clusterView}/{databaseName}/{collectionName}/collStats/measurements' + ) + ).toEqual('getGroupClusterCollStatMeasurements'); + expect(generateOperationID('grant', '/api/atlas/v2/groups/{groupId}/access')).toEqual('grantGroupAccess'); + }); + + it('should split camelCase method names', () => { + expect(generateOperationID('addNode', '/groups/{groupId}/clusters/{clusterName}')).toEqual('addGroupClusterNode'); + expect(generateOperationID('get', '/api/atlas/v2/groups/byName/{groupName}')).toEqual('getGroupByName'); + expect(generateOperationID('', '/api/atlas/v2/groups/{groupId}/backup/exportBuckets/{exportBucketId}')).toEqual( + 'exportGroupBackupBuckets' + ); + }); - it('should leave the final noun as is', () => { - expect(generateOperationID('list', '/groups/{groupId}/clusters')).toEqual('listGroupClusters'); - expect(generateOperationID('get', '/groups/{groupId}/settings')).toEqual('getGroupSettings'); - expect(generateOperationID('update', '/groups/{groupId}/settings')).toEqual('updateGroupSettings'); - expect(generateOperationID('search', '/groups/{groupId}/clusters')).toEqual('searchGroupClusters'); - expect( - generateOperationID( - 'get', - '/groups/{groupId}/clusters/{clusterName}/{clusterView}/{databaseName}/{collectionName}/collStats/measurements' - ) - ).toEqual('getGroupClusterCollStatMeasurements'); - expect(generateOperationID('grant', '/api/atlas/v2/groups/{groupId}/access')).toEqual('grantGroupAccess'); + it('should accommodate legacy custom methods', () => { + expect(generateOperationID('', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/restartPrimaries')).toEqual( + 'restartGroupClusterPrimaries' + ); + expect(generateOperationID('', '/api/atlas/v2/groups/{groupId}/pipelines/{pipelineName}/pause')).toEqual( + 'pauseGroupPipeline' + ); + }); + + it('should return method when path is empty', () => { + expect(generateOperationID('get', '')).toEqual('get'); + expect(generateOperationID('getInfo', '')).toEqual('getInfo'); + }); }); - it('should split camelCase method names', () => { - expect(generateOperationID('addNode', '/groups/{groupId}/clusters/{clusterName}')).toEqual('addGroupClusterNode'); - expect(generateOperationID('get', '/api/atlas/v2/groups/byName/{groupName}')).toEqual('getGroupByName'); - expect(generateOperationID('', '/api/atlas/v2/groups/{groupId}/backup/exportBuckets/{exportBucketId}')).toEqual( - 'exportGroupBackupBuckets' - ); + describe('numberOfWords', () => { + it('should count the number of words in a camelCase string', () => { + expect(numberOfWords('create')).toEqual(1); + expect(numberOfWords('createGroup')).toEqual(2); + expect(numberOfWords('createGroupCluster')).toEqual(3); + expect(numberOfWords('createGroupClusterIndex')).toEqual(4); + expect(numberOfWords('')).toEqual(0); + }); }); - it('should accommodate legacy custom methods', () => { - expect(generateOperationID('', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/restartPrimaries')).toEqual( - 'restartGroupClusterPrimaries' - ); - expect(generateOperationID('', '/api/atlas/v2/groups/{groupId}/pipelines/{pipelineName}/pause')).toEqual( - 'pauseGroupPipeline' - ); + describe('shortenOperationId', () => { + it('should shorten operation IDs correctly', () => { + expect(shortenOperationId('createGroupClusterAutoScalingConfiguration')).toEqual( + 'createAutoScalingConfiguration' + ); + expect(shortenOperationId('getFederationSettingConnectedOrgConfigRoleMapping')).toEqual('getConfigRoleMapping'); + }); + + it('should make no change if the operation ID is <= 4 words long or undefined', () => { + expect(shortenOperationId('createGroupClusterIndex')).toEqual('createGroupClusterIndex'); + expect(shortenOperationId('create')).toEqual('create'); + expect(shortenOperationId('')).toEqual(''); + }); }); }); diff --git a/tools/spectral/ipa/__tests__/utils/validations/validateOperationIdAndReturnErrors.test.js b/tools/spectral/ipa/__tests__/utils/validations/validateOperationIdAndReturnErrors.test.js new file mode 100644 index 0000000000..b9e6b88b03 --- /dev/null +++ b/tools/spectral/ipa/__tests__/utils/validations/validateOperationIdAndReturnErrors.test.js @@ -0,0 +1,404 @@ +import { describe, expect, it } from '@jest/globals'; +import { validateOperationIdAndReturnErrors } from '../../../rulesets/functions/utils/validations/validateOperationIdAndReturnErrors.js'; + +describe('tools/spectral/ipa/rulesets/functions/utils/validations/validateOperationIdAndReturnErrors.js', () => { + it('should return no errors for valid operation ID', () => { + expect( + validateOperationIdAndReturnErrors( + 'get', + '/resource', + { + operationId: 'getResource', + }, + ['paths', '/resource', 'get'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}', + { + operationId: 'getSomeResource', + }, + ['paths', '/some/{id}/resource/{resourceId}', 'get'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'update', + '/some/{id}/resource/{resourceId}', + { + operationId: 'updateSomeResource', + }, + ['paths', '/some/{id}/resource/{resourceId}', 'patch'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'delete', + '/some/{id}/resource/{resourceId}', + { + operationId: 'deleteSomeResource', + }, + ['paths', '/some/{id}/resource/{resourceId}', 'delete'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'list', + '/some/{id}/resource', + { + operationId: 'listSomeResource', + }, + ['paths', '/some/{id}/resource', 'get'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'create', + '/some/{id}/resource', + { + operationId: 'createSomeResource', + }, + ['paths', '/some/{id}/resource', 'post'] + ) + ).toHaveLength(0); + + // Custom methods + expect( + validateOperationIdAndReturnErrors( + 'getRoot', + '/', + { + operationId: 'getRoot', + }, + ['paths', '/', 'get'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'toggle', + '/some/{id}/setting', + { + operationId: 'toggleSomeSetting', + }, + ['paths', '/some/{id}/setting:toggle', 'post'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'toggle', + '/some/{id}/feature/{featureName}', + { + operationId: 'toggleSomeFeature', + }, + ['paths', '/some/{id}/feature/{featureName}:toggle', 'post'] + ) + ).toHaveLength(0); + + // Legacy custom methods + expect( + validateOperationIdAndReturnErrors( + '', + '/some/{id}/feature/{featureName}/toggle', + { + operationId: 'toggleSomeFeature', + }, + ['paths', '/some/{id}/feature/{featureName}/toggle', 'post'] + ) + ).toHaveLength(0); + }); + + it('should return no errors for valid operation ID override', () => { + expect( + validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}/long/{id}/childResource/{id}', + { + operationId: 'getSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'getChildResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource/{id}', 'get'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'update', + '/some/{id}/resource/{resourceId}/long/{id}/childResource/{id}', + { + operationId: 'updateSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'updateChildResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource/{id}', 'patch'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'delete', + '/some/{id}/resource/{resourceId}/long/{id}/childResource/{id}', + { + operationId: 'deleteSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'deleteChildResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource/{id}', 'delete'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'list', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + { + operationId: 'listSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'listChildResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource', 'get'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'create', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + { + operationId: 'createSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'createChildResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource', 'post'] + ) + ).toHaveLength(0); + + // Custom methods + expect( + validateOperationIdAndReturnErrors( + 'toggle', + '/some/{id}/resource/{resourceId}/long/{id}/setting', + { + operationId: 'toggleSomeResourceLongSetting', + 'x-xgen-operation-id-override': 'toggleSomeSetting', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/setting:toggle', 'post'] + ) + ).toHaveLength(0); + expect( + validateOperationIdAndReturnErrors( + 'toggle', + '/some/{id}/resource/{resourceId}/long/{id}/feature/{featureName}', + { + operationId: 'toggleSomeResourceLongFeature', + 'x-xgen-operation-id-override': 'toggleSomeFeature', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/feature/{featureName}:toggle', 'post'] + ) + ).toHaveLength(0); + + // Nouns in word swapped order + expect( + validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}/long/{id}/childResource/{id}', + { + operationId: 'getSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'getChildLongResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource/{id}', 'get'] + ) + ).toHaveLength(0); + }); + + it('should return errors for invalid operation ID', () => { + expect( + validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}', + { + operationId: 'invalidOperationId', + }, + ['paths', '/some/{id}/resource/{resourceId}', 'get'] + ) + ).toEqual([ + { + path: ['paths', '/some/{id}/resource/{resourceId}', 'get', 'operationId'], + message: "Invalid OperationID. Found 'invalidOperationId', expected 'getSomeResource'.", + }, + ]); + + // Custom method + expect( + validateOperationIdAndReturnErrors( + 'toggle', + '/some/{id}/resource/{resourceId}', + { + operationId: 'createSomeResource', + }, + ['paths', '/some/{id}/resource/{resourceId}:toggle', 'post'] + ) + ).toEqual([ + { + path: ['paths', '/some/{id}/resource/{resourceId}:toggle', 'post', 'operationId'], + message: "Invalid OperationID. Found 'createSomeResource', expected 'toggleSomeResource'.", + }, + ]); + }); + + it('should return errors for too long operation ID without override', () => { + expect( + validateOperationIdAndReturnErrors( + 'create', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + { + operationId: 'createSomeResourceLongChildResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource', 'post'] + ) + ).toEqual([ + { + path: ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource', 'post', 'operationId'], + message: + "The Operation ID is longer than 4 words. Please add an 'x-xgen-operation-id-override' extension to the operation with a shorter operation ID. For example: 'createLongChildResource'.", + }, + ]); + }); + + it('should return errors for valid operation ID with unnecessary override', () => { + expect( + validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}', + { + operationId: 'getSomeResource', + 'x-xgen-operation-id-override': 'getResource', + }, + ['paths', '/some/{id}/resource/{resourceId}', 'get'] + ) + ).toEqual([ + { + path: ['paths', '/some/{id}/resource/{resourceId}', 'get', 'x-xgen-operation-id-override'], + message: + "Please remove the 'x-xgen-operation-id-override' extension from the operation. The Operation ID already has a valid length (<=4 words).", + }, + ]); + }); + + it('should return errors for operation ID override with wrong verb', () => { + expect( + validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + { + operationId: 'getSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'listResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource', 'get'] + ) + ).toEqual([ + { + path: [ + 'paths', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + 'get', + 'x-xgen-operation-id-override', + ], + message: "The operation ID override must start with the verb 'get'.", + }, + ]); + }); + + it('should return errors for too long operation ID override', () => { + expect( + validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + { + operationId: 'getSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'getSomeResourceChildResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource', 'get'] + ) + ).toEqual([ + { + path: [ + 'paths', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + 'get', + 'x-xgen-operation-id-override', + ], + message: 'The operation ID override is longer than 4 words. Please shorten it.', + }, + ]); + }); + + it('should return errors for operation ID override with invalid nouns', () => { + expect( + validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + { + operationId: 'getSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'getSomeSpecialResource', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource', 'get'] + ) + ).toEqual([ + { + path: [ + 'paths', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + 'get', + 'x-xgen-operation-id-override', + ], + message: + "The operation ID override must only contain nouns from the operation ID 'getSomeResourceLongChildResource'.", + }, + ]); + }); + + it('should return errors for operation ID override with invalid last noun', () => { + expect( + validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + { + operationId: 'getSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'getSomeChild', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource', 'get'] + ) + ).toEqual([ + { + path: [ + 'paths', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + 'get', + 'x-xgen-operation-id-override', + ], + message: "The operation ID override must end with the noun 'Resource'.", + }, + ]); + }); + + it('should return all override errors for invalid operation ID override', () => { + const errors = validateOperationIdAndReturnErrors( + 'get', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + { + operationId: 'getSomeResourceLongChildResource', + 'x-xgen-operation-id-override': 'createSimpleCoffeeVanillaSyrup', + }, + ['paths', '/some/{id}/resource/{resourceId}/long/{id}/childResource', 'get'] + ); + expect(errors).toHaveLength(4); + errors.forEach((error) => { + expect(error.path).toEqual([ + 'paths', + '/some/{id}/resource/{resourceId}/long/{id}/childResource', + 'get', + 'x-xgen-operation-id-override', + ]); + }); + expect(errors[0].message).toEqual("The operation ID override must start with the verb 'get'."); + expect(errors[1].message).toEqual('The operation ID override is longer than 4 words. Please shorten it.'); + expect(errors[2].message).toEqual( + "The operation ID override must only contain nouns from the operation ID 'getSomeResourceLongChildResource'." + ); + expect(errors[3].message).toEqual("The operation ID override must end with the noun 'Resource'."); + }); +}); diff --git a/tools/spectral/ipa/rulesets/functions/IPA104ValidOperationID.js b/tools/spectral/ipa/rulesets/functions/IPA104ValidOperationID.js index 9786501d9a..85233f334f 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA104ValidOperationID.js +++ b/tools/spectral/ipa/rulesets/functions/IPA104ValidOperationID.js @@ -1,4 +1,9 @@ -import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; +import { + collectAdoption, + collectAndReturnViolation, + collectException, + handleInternalError, +} from './utils/collectionUtils.js'; import { hasException } from './utils/exceptions.js'; import { getResourcePathItems, isCustomMethodIdentifier } from './utils/resourceEvaluation.js'; import { hasCustomMethodOverride, hasMethodVerbOverride } from './utils/extensions.js'; @@ -26,11 +31,15 @@ export default (input, { methodName }, { path, documentInventory }) => { return; } - const errors = validateOperationIdAndReturnErrors(methodName, resourcePath, input, path); + try { + const errors = validateOperationIdAndReturnErrors(methodName, resourcePath, input, path); - if (errors.length > 0) { - return collectAndReturnViolation(path, RULE_NAME, errors); - } + if (errors.length > 0) { + return collectAndReturnViolation(path, RULE_NAME, errors); + } - return collectAdoption(path, RULE_NAME); + return collectAdoption(path, RULE_NAME); + } catch (e) { + return handleInternalError(RULE_NAME, path, e); + } }; diff --git a/tools/spectral/ipa/rulesets/functions/IPA105ValidOperationID.js b/tools/spectral/ipa/rulesets/functions/IPA105ValidOperationID.js index fef0997cca..debc95cf91 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA105ValidOperationID.js +++ b/tools/spectral/ipa/rulesets/functions/IPA105ValidOperationID.js @@ -1,12 +1,16 @@ import { hasException } from './utils/exceptions.js'; -import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; +import { + collectAdoption, + collectAndReturnViolation, + collectException, + handleInternalError, +} from './utils/collectionUtils.js'; import { getResourcePathItems, isCustomMethodIdentifier } from './utils/resourceEvaluation.js'; -import { generateOperationID } from './utils/operationIdGeneration.js'; import { isInvalidListMethod } from './utils/methodLogic.js'; import { hasCustomMethodOverride, hasMethodVerbOverride } from './utils/extensions.js'; +import { validateOperationIdAndReturnErrors } from './utils/validations/validateOperationIdAndReturnErrors.js'; const RULE_NAME = 'xgen-IPA-105-valid-operation-id'; -const ERROR_MESSAGE = 'Invalid OperationID.'; export default (input, { methodName }, { path, documentInventory }) => { const resourcePath = path[1]; @@ -27,16 +31,15 @@ export default (input, { methodName }, { path, documentInventory }) => { return; } - const expectedOperationId = generateOperationID(methodName, resourcePath); - if (expectedOperationId !== input.operationId) { - const errors = [ - { - path, - message: `${ERROR_MESSAGE} Found ${input.operationId}, expected ${expectedOperationId}.`, - }, - ]; - return collectAndReturnViolation(path, RULE_NAME, errors); - } + try { + const errors = validateOperationIdAndReturnErrors(methodName, resourcePath, input, path); + + if (errors.length > 0) { + return collectAndReturnViolation(path, RULE_NAME, errors); + } - return collectAdoption(path, RULE_NAME); + return collectAdoption(path, RULE_NAME); + } catch (e) { + return handleInternalError(RULE_NAME, path, e); + } }; diff --git a/tools/spectral/ipa/rulesets/functions/IPA106ValidOperationID.js b/tools/spectral/ipa/rulesets/functions/IPA106ValidOperationID.js index 428fd33b20..9b21b823dc 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA106ValidOperationID.js +++ b/tools/spectral/ipa/rulesets/functions/IPA106ValidOperationID.js @@ -1,11 +1,15 @@ import { hasException } from './utils/exceptions.js'; -import { collectAdoption, collectException, collectAndReturnViolation } from './utils/collectionUtils.js'; +import { + collectAdoption, + collectException, + collectAndReturnViolation, + handleInternalError, +} from './utils/collectionUtils.js'; import { isCustomMethodIdentifier } from './utils/resourceEvaluation.js'; -import { generateOperationID } from './utils/operationIdGeneration.js'; import { hasCustomMethodOverride } from './utils/extensions.js'; +import { validateOperationIdAndReturnErrors } from './utils/validations/validateOperationIdAndReturnErrors.js'; const RULE_NAME = 'xgen-IPA-106-valid-operation-id'; -const ERROR_MESSAGE = 'Invalid OperationID.'; export default (input, { methodName }, { path }) => { const resourcePath = path[1]; @@ -19,16 +23,15 @@ export default (input, { methodName }, { path }) => { return; } - const expectedOperationID = generateOperationID(methodName, resourcePath); - if (expectedOperationID !== input.operationId) { - const errors = [ - { - path, - message: `${ERROR_MESSAGE} Found ${input.operationId}, expected ${expectedOperationID}.`, - }, - ]; - return collectAndReturnViolation(path, RULE_NAME, errors); - } + try { + const errors = validateOperationIdAndReturnErrors(methodName, resourcePath, input, path); + + if (errors.length > 0) { + return collectAndReturnViolation(path, RULE_NAME, errors); + } - collectAdoption(path, RULE_NAME); + collectAdoption(path, RULE_NAME); + } catch (e) { + return handleInternalError(RULE_NAME, path, e); + } }; diff --git a/tools/spectral/ipa/rulesets/functions/IPA107ValidOperationID.js b/tools/spectral/ipa/rulesets/functions/IPA107ValidOperationID.js index 1500354412..bf8b9b284d 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA107ValidOperationID.js +++ b/tools/spectral/ipa/rulesets/functions/IPA107ValidOperationID.js @@ -1,11 +1,15 @@ import { hasException } from './utils/exceptions.js'; -import { collectAdoption, collectException, collectAndReturnViolation } from './utils/collectionUtils.js'; +import { + collectAdoption, + collectException, + collectAndReturnViolation, + handleInternalError, +} from './utils/collectionUtils.js'; import { isCustomMethodIdentifier } from './utils/resourceEvaluation.js'; -import { generateOperationID } from './utils/operationIdGeneration.js'; import { hasCustomMethodOverride } from './utils/extensions.js'; +import { validateOperationIdAndReturnErrors } from './utils/validations/validateOperationIdAndReturnErrors.js'; const RULE_NAME = 'xgen-IPA-107-valid-operation-id'; -const ERROR_MESSAGE = 'Invalid OperationID.'; export default (input, { methodName }, { path }) => { const resourcePath = path[1]; @@ -19,16 +23,15 @@ export default (input, { methodName }, { path }) => { return; } - const expectedOperationID = generateOperationID(methodName, resourcePath); - if (expectedOperationID !== input.operationId) { - const errors = [ - { - path, - message: `${ERROR_MESSAGE} Found ${input.operationId}, expected ${expectedOperationID}.`, - }, - ]; - return collectAndReturnViolation(path, RULE_NAME, errors); - } + try { + const errors = validateOperationIdAndReturnErrors(methodName, resourcePath, input, path); + + if (errors.length > 0) { + return collectAndReturnViolation(path, RULE_NAME, errors); + } - collectAdoption(path, RULE_NAME); + collectAdoption(path, RULE_NAME); + } catch (e) { + return handleInternalError(RULE_NAME, path, e); + } }; diff --git a/tools/spectral/ipa/rulesets/functions/IPA108ValidOperationID.js b/tools/spectral/ipa/rulesets/functions/IPA108ValidOperationID.js index 7e049a815b..dd84bd7088 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA108ValidOperationID.js +++ b/tools/spectral/ipa/rulesets/functions/IPA108ValidOperationID.js @@ -1,11 +1,15 @@ import { hasException } from './utils/exceptions.js'; -import { collectAdoption, collectException, collectAndReturnViolation } from './utils/collectionUtils.js'; +import { + collectAdoption, + collectException, + collectAndReturnViolation, + handleInternalError, +} from './utils/collectionUtils.js'; import { isCustomMethodIdentifier } from './utils/resourceEvaluation.js'; -import { generateOperationID } from './utils/operationIdGeneration.js'; import { hasCustomMethodOverride } from './utils/extensions.js'; +import { validateOperationIdAndReturnErrors } from './utils/validations/validateOperationIdAndReturnErrors.js'; const RULE_NAME = 'xgen-IPA-108-valid-operation-id'; -const ERROR_MESSAGE = 'Invalid OperationID.'; export default (input, { methodName }, { path }) => { const resourcePath = path[1]; @@ -19,16 +23,15 @@ export default (input, { methodName }, { path }) => { return; } - const expectedOperationID = generateOperationID(methodName, resourcePath); - if (expectedOperationID !== input.operationId) { - const errors = [ - { - path, - message: `${ERROR_MESSAGE} Found ${input.operationId}, expected ${expectedOperationID}.`, - }, - ]; - return collectAndReturnViolation(path, RULE_NAME, errors); - } + try { + const errors = validateOperationIdAndReturnErrors(methodName, resourcePath, input, path); + + if (errors.length > 0) { + return collectAndReturnViolation(path, RULE_NAME, errors); + } - collectAdoption(path, RULE_NAME); + collectAdoption(path, RULE_NAME); + } catch (e) { + return handleInternalError(RULE_NAME, path, e); + } }; diff --git a/tools/spectral/ipa/rulesets/functions/IPA109ValidOperationID.js b/tools/spectral/ipa/rulesets/functions/IPA109ValidOperationID.js index ba432acb4e..50fdc24fe2 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA109ValidOperationID.js +++ b/tools/spectral/ipa/rulesets/functions/IPA109ValidOperationID.js @@ -1,16 +1,20 @@ import { hasException } from './utils/exceptions.js'; -import { collectAdoption, collectException, collectAndReturnViolation } from './utils/collectionUtils.js'; +import { + collectAdoption, + collectException, + collectAndReturnViolation, + handleInternalError, +} from './utils/collectionUtils.js'; import { isCustomMethodIdentifier, getCustomMethodName, stripCustomMethodName } from './utils/resourceEvaluation.js'; -import { generateOperationID } from './utils/operationIdGeneration.js'; -import { hasMethodWithVerbOverride, hasCustomMethodOverride, VERB_OVERRIDE_EXTENSION } from './utils/extensions.js'; +import { hasCustomMethodOverride, VERB_OVERRIDE_EXTENSION, hasVerbOverride } from './utils/extensions.js'; +import { validateOperationIdAndReturnErrors } from './utils/validations/validateOperationIdAndReturnErrors.js'; const RULE_NAME = 'xgen-IPA-109-valid-operation-id'; -const ERROR_MESSAGE = 'Invalid OperationID.'; export default (input, _, { path }) => { - let resourcePath = path[1]; + const resourcePath = path[1]; - if (!isCustomMethodIdentifier(resourcePath) && !hasMethodWithVerbOverride(input)) { + if (!isCustomMethodIdentifier(resourcePath) && !hasCustomMethodOverride(input)) { return; } @@ -19,52 +23,30 @@ export default (input, _, { path }) => { return; } - if (isCustomMethodIdentifier(resourcePath)) { - let obj; - if (input.post) { - obj = input.post; - } else if (input.get) { - obj = input.get; + let methodName; + let endpointUrl = resourcePath; + + try { + if (isCustomMethodIdentifier(resourcePath)) { + // Standard custom methods + methodName = getCustomMethodName(resourcePath); + endpointUrl = stripCustomMethodName(resourcePath); + } else if (hasVerbOverride(input)) { + // Legacy custom methods + methodName = input[VERB_OVERRIDE_EXTENSION].verb; + endpointUrl = resourcePath; } else { return; } - const operationId = obj.operationId; - const expectedOperationID = generateOperationID( - getCustomMethodName(resourcePath), - stripCustomMethodName(resourcePath) - ); - if (operationId !== expectedOperationID) { - const errors = [ - { - path, - message: `${ERROR_MESSAGE} Found ${operationId}, expected ${expectedOperationID}.`, - }, - ]; - return collectAndReturnViolation(path, RULE_NAME, errors); - } - } else if (hasMethodWithVerbOverride(input)) { - const methods = Object.values(input); - let errors = []; - methods.forEach((method) => { - if (hasCustomMethodOverride(method)) { - const operationId = method.operationId; - const expectedOperationID = generateOperationID(method[VERB_OVERRIDE_EXTENSION].verb, resourcePath); - if (operationId !== expectedOperationID) { - errors.push({ - path, - message: `${ERROR_MESSAGE} Found ${operationId}, expected ${expectedOperationID}.`, - }); - } - } - }); + const errors = validateOperationIdAndReturnErrors(methodName, endpointUrl, input, path); if (errors.length !== 0) { return collectAndReturnViolation(path, RULE_NAME, errors); } - } else { - return; - } - collectAdoption(path, RULE_NAME); + collectAdoption(path, RULE_NAME); + } catch (e) { + return handleInternalError(RULE_NAME, path, e); + } }; diff --git a/tools/spectral/ipa/rulesets/functions/utils/extensions.js b/tools/spectral/ipa/rulesets/functions/utils/extensions.js index 335d2be13c..1f9e162076 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/extensions.js +++ b/tools/spectral/ipa/rulesets/functions/utils/extensions.js @@ -1,14 +1,5 @@ export const VERB_OVERRIDE_EXTENSION = 'x-xgen-method-verb-override'; - -/** - * Checks if the endpoint has a method with an extension "x-xgen-method-verb-override" - * - * @param endpoint the endpoint to evaluate - * @returns {boolean} true if the endpoint has a nested method with the extension, otherwise false - */ -export function hasMethodWithVerbOverride(endpoint) { - return Object.values(endpoint).some(hasVerbOverride); -} +export const OPERATION_ID_OVERRIDE_EXTENSION = 'x-xgen-operation-id-override'; /** * Checks if the object has an extension "x-xgen-method-verb-override" with the customMethod boolean set to true @@ -32,14 +23,31 @@ export function hasMethodVerbOverride(object, verb) { } /** - * Checks if the object has an extension "x-xgen-method-verb-override" + * Checks if the operation has an extension "x-xgen-method-verb-override" * - * @param object the object to evaluate - * @returns {boolean} true if the object has the extension, otherwise false + * @param operation the operation to evaluate + * @returns {boolean} true if the operation has the extension, otherwise false + */ +export function hasVerbOverride(operation) { + return Object.keys(operation).includes(VERB_OVERRIDE_EXTENSION); +} + +/** + * Checks if the endpoint has a method with an extension "x-xgen-operation-id-override" + * + * @param operation the endpoint to evaluate + * @returns {boolean} true if the endpoint has a nested method with the extension, otherwise false + */ +export function hasOperationIdOverride(operation) { + return Object.keys(operation).includes(OPERATION_ID_OVERRIDE_EXTENSION); +} + +/** + * Returns the operation id override from the endpoint. + * + * @param endpoint the endpoint to evaluate + * @returns {string|undefined} the operation id override if it exists, otherwise undefined */ -function hasVerbOverride(object) { - if (!object[VERB_OVERRIDE_EXTENSION]) { - return false; - } - return true; +export function getOperationIdOverride(endpoint) { + return endpoint[OPERATION_ID_OVERRIDE_EXTENSION]; } diff --git a/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js b/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js index a3fcda762a..4299b760e0 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js +++ b/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js @@ -56,6 +56,28 @@ export function generateOperationID(method, path) { return opID; } +/** + * Counts the number of words in a camelCase string. + * @param operationId + * @returns {number} + */ +export function numberOfWords(operationId) { + return operationId.match(CAMEL_CASE)?.length || 0; +} + +/** + * Shortens an operation ID to the first word (verb) and last 3 words. + * @param operationId + * @returns {string} + */ +export function shortenOperationId(operationId) { + const words = operationId.match(CAMEL_CASE); + if (!words || words.length < 4) { + return operationId; // Return as is if there are not enough words to shorten + } + return words[0] + words.slice(words.length - 3).join(''); +} + /** * Derives action verb from custom method name. Returns standard method names as is. * Assumes the first word of camelCase method names is the action verb. diff --git a/tools/spectral/ipa/rulesets/functions/utils/validations/validateOperationIdAndReturnErrors.js b/tools/spectral/ipa/rulesets/functions/utils/validations/validateOperationIdAndReturnErrors.js new file mode 100644 index 0000000000..a5acd6a9b2 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/utils/validations/validateOperationIdAndReturnErrors.js @@ -0,0 +1,97 @@ +import { generateOperationID, numberOfWords, shortenOperationId } from '../operationIdGeneration.js'; +import { getOperationIdOverride, hasOperationIdOverride, OPERATION_ID_OVERRIDE_EXTENSION } from '../extensions.js'; + +const CAMEL_CASE = /[A-Z]?[a-z]+/g; + +const INVALID_OP_ID_ERROR_MESSAGE = 'Invalid OperationID.'; +const TOO_LONG_OP_ID_ERROR_MESSAGE = + "The Operation ID is longer than 4 words. Please add an '" + + OPERATION_ID_OVERRIDE_EXTENSION + + "' extension to the operation with a shorter operation ID."; +const REMOVE_OP_ID_OVERRIDE_ERROR_MESSAGE = + "Please remove the '" + + OPERATION_ID_OVERRIDE_EXTENSION + + "' extension from the operation. The Operation ID already has a valid length (<=4 words)."; + +/** + * Validates the operationId of an operation object and returns errors if it does not match the expected format. Also validates that the operationId override, if present, follows the expected rules. + * + * @param methodName the method name (e.g. 'get', 'post', etc.). For custom methods, this is the custom method name. For legacy custom methods, this is an empty string. + * @param resourcePath the resource path for the endpoint (e.g. '/users', '/users/{userId}', etc.). For custom methods, this is the path without the custom method name. + * @param operationObject the operation object to validate, which should contain the operationId and optionally the x-xgen-operation-id-override extension. + * @param path the path to the operation object being evaluated, used for error reporting with Spectral. + * @returns {[{path: string[], message: string}]} an array of error objects, each containing a path and a message, or an empty array if no errors are found. + */ +export function validateOperationIdAndReturnErrors(methodName, resourcePath, operationObject, path) { + const operationId = operationObject.operationId; + const expectedOperationId = generateOperationID(methodName, resourcePath); + + const operationIdPath = path.concat(['operationId']); + + const errors = []; + if (expectedOperationId !== operationId) { + errors.push({ + path: operationIdPath, + message: `${INVALID_OP_ID_ERROR_MESSAGE} Found '${operationId}', expected '${expectedOperationId}'.`, + }); + } + + const operationIdOverridePath = path.concat([OPERATION_ID_OVERRIDE_EXTENSION]); + if (numberOfWords(operationId) > 4) { + if (!hasOperationIdOverride(operationObject)) { + errors.push({ + path: operationIdPath, + message: TOO_LONG_OP_ID_ERROR_MESSAGE + " For example: '" + shortenOperationId(expectedOperationId) + "'.", + }); + return errors; + } + const overrideErrors = validateOperationIdOverride( + operationIdOverridePath, + getOperationIdOverride(operationObject), + expectedOperationId + ); + errors.push(...overrideErrors); + } else if (hasOperationIdOverride(operationObject)) { + errors.push({ + path: operationIdOverridePath, + message: REMOVE_OP_ID_OVERRIDE_ERROR_MESSAGE, + }); + } + return errors; +} + +function validateOperationIdOverride(operationIdOverridePath, override, expectedOperationId) { + const expectedVerb = expectedOperationId.match(CAMEL_CASE)[0]; + const errors = []; + if (!override.startsWith(expectedVerb)) { + errors.push({ + path: operationIdOverridePath, + message: `The operation ID override must start with the verb '${expectedVerb}'.`, + }); + } + + if (numberOfWords(override) > 4) { + errors.push({ + path: operationIdOverridePath, + message: `The operation ID override is longer than 4 words. Please shorten it.`, + }); + } + + const overrideWords = override.match(CAMEL_CASE).slice(1); + if (overrideWords.some((word) => !expectedOperationId.includes(word))) { + errors.push({ + path: operationIdOverridePath, + message: `The operation ID override must only contain nouns from the operation ID '${expectedOperationId}'.`, + }); + } + + const expectedLastNoun = expectedOperationId.match(CAMEL_CASE)[numberOfWords(expectedOperationId) - 1]; + if (!override.endsWith(expectedLastNoun)) { + errors.push({ + path: operationIdOverridePath, + message: `The operation ID override must end with the noun '${expectedLastNoun}'.`, + }); + } + + return errors; +}