diff --git a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js index b6c0522412..22310e53c4 100644 --- a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js +++ b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js @@ -29,6 +29,66 @@ describe('tools/spectral/ipa/utils/operationIdGeneration.js', () => { expect(generateOperationID('grant', '/api/atlas/v2/groups/{groupId}/access')).toEqual('grantGroupAccess'); }); + it('should preserve the resource scope of operations paths', () => { + // the collection-scoped paths keep the parent plural, the instance-scoped paths keep it singular, + // so that the two Operations resources do not share an operation ID + expect(generateOperationID('list', '/api/atlas/v2/groups/{groupId}/clusters/operations')).toEqual( + 'listGroupClustersOperations' + ); + expect(generateOperationID('list', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations')).toEqual( + 'listGroupClusterOperations' + ); + expect(generateOperationID('get', '/api/atlas/v2/groups/{groupId}/clusters/operations/{operationId}')).toEqual( + 'getGroupClustersOperation' + ); + expect( + generateOperationID('get', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations/{operationId}') + ).toEqual('getGroupClusterOperation'); + }); + + it('should generate distinct operation IDs for collection- and instance-scoped operations resources', () => { + expect(generateOperationID('list', '/api/atlas/v2/groups/{groupId}/clusters/operations')).not.toEqual( + generateOperationID('list', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations') + ); + expect( + generateOperationID('get', '/api/atlas/v2/groups/{groupId}/clusters/operations/{operationId}') + ).not.toEqual( + generateOperationID('get', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations/{operationId}') + ); + }); + + it('should not preserve the parent plural for other operations paths', () => { + // the parent is a single resource, so the existing singularization applies + expect(generateOperationID('list', '/api/atlas/v2/groups/{groupId}/operations')).toEqual('listGroupOperations'); + // 'operations' is not the trailing resource section + expect(generateOperationID('list', '/api/atlas/v2/groups/{groupId}/clusters/operations/logs')).toEqual( + 'listGroupClusterOperationLogs' + ); + // no parent resource to scope to + expect(generateOperationID('list', '/api/atlas/v2/operations')).toEqual('listOperations'); + // multi-word custom methods append their own trailing noun, so the parent is singularized as before + expect(generateOperationID('listPending', '/api/atlas/v2/groups/{groupId}/clusters/operations')).toEqual( + 'listGroupClusterOperationPending' + ); + }); + + it('should not affect operation IDs for non-operations paths', () => { + // nested resource collection + expect(generateOperationID('list', '/api/atlas/v2/groups/{groupId}/clusters')).toEqual('listGroupClusters'); + // single resource + expect(generateOperationID('get', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}')).toEqual( + 'getGroupCluster' + ); + // multi-word custom method + expect(generateOperationID('addNode', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}')).toEqual( + 'addGroupClusterNode' + ); + // legacy custom method + expect(generateOperationID('', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/restartPrimaries')).toEqual( + 'restartGroupClusterPrimaries' + ); + }); + 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'); diff --git a/tools/spectral/ipa/rulesets/IPA-104.yaml b/tools/spectral/ipa/rulesets/IPA-104.yaml index 171d932a02..c3716443a4 100644 --- a/tools/spectral/ipa/rulesets/IPA-104.yaml +++ b/tools/spectral/ipa/rulesets/IPA-104.yaml @@ -104,6 +104,7 @@ rules: The Operation ID must start with the verb “get” and should be followed by a noun or compound noun. The noun(s) in the Operation ID should be the collection identifiers from the resource identifier in singular form. If the resource is a singleton resource, the last noun may be the plural form of the collection identifier. + For a collection-scoped Operations resource, such as '/clusters/operations/{operationId}', the parent noun is in plural form, so that the Operation ID differs from the one for the instance-scoped Operations resource, such as '/clusters/{clusterName}/operations/{operationId}'. ##### Implementation details Rule checks for the following conditions: diff --git a/tools/spectral/ipa/rulesets/IPA-105.yaml b/tools/spectral/ipa/rulesets/IPA-105.yaml index 55efd0a485..f30641481e 100644 --- a/tools/spectral/ipa/rulesets/IPA-105.yaml +++ b/tools/spectral/ipa/rulesets/IPA-105.yaml @@ -83,6 +83,7 @@ rules: description: | The Operation ID must start with the verb “list” and should be followed by a noun or compound noun. The noun(s) in the Operation ID should be the collection identifiers from the resource identifier in singular form, where the last noun is in plural form. + For a collection-scoped Operations resource, such as '/clusters/operations', the parent noun is also in plural form, so that the Operation ID differs from the one for the instance-scoped Operations resource, such as '/clusters/{clusterName}/operations'. ##### Implementation details Rule checks for the following conditions: diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index f5a4957968..dc0df8fba4 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -152,6 +152,7 @@ Rule checks for the following conditions: The Operation ID must start with the verb “get” and should be followed by a noun or compound noun. The noun(s) in the Operation ID should be the collection identifiers from the resource identifier in singular form. If the resource is a singleton resource, the last noun may be the plural form of the collection identifier. +For a collection-scoped Operations resource, such as '/clusters/operations/{operationId}', the parent noun is in plural form, so that the Operation ID differs from the one for the instance-scoped Operations resource, such as '/clusters/{clusterName}/operations/{operationId}'. ##### Implementation details Rule checks for the following conditions: @@ -242,6 +243,7 @@ The response body of the List method should consist of the same resource object ![error](https://img.shields.io/badge/error-red) The Operation ID must start with the verb “list” and should be followed by a noun or compound noun. The noun(s) in the Operation ID should be the collection identifiers from the resource identifier in singular form, where the last noun is in plural form. +For a collection-scoped Operations resource, such as '/clusters/operations', the parent noun is also in plural form, so that the Operation ID differs from the one for the instance-scoped Operations resource, such as '/clusters/{clusterName}/operations'. ##### Implementation details Rule checks for the following conditions: diff --git a/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js b/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js index d247caf5ff..3796074faf 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js +++ b/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js @@ -3,6 +3,7 @@ import { isPathParam, removePrefix, isSingleResourceIdentifier } from './resourc const CAMEL_CASE = /[A-Z]?[a-z]+/g; export const CAMEL_CASE_WITH_ABBREVIATIONS = /[A-Z]+(?![a-z0-9])|[A-Z]*[a-z0-9]+/g; +const OPERATIONS_SECTION = 'operations'; /** * Returns IPA Compliant Operation ID. @@ -39,9 +40,16 @@ export function generateOperationID(method, path, ignoreSingularizationList = [] nouns.push(method.slice(verb.length)); } + // a collection-scoped Operations resource keeps its parent's plural form, so that its operation ID + // does not collide with the instance-scoped Operations resource of the same parent + const keepParentPlural = isCollectionScopedOperationsPath(resourceIdentifier) && !camelCaseCustomMethod; + let opID = verb; for (let i = 0; i < nouns.length - 1; i++) { - opID += upperCamelCase(singularize(nouns[i], ignoreSingularizationList)); + const isParentOfOperations = i === nouns.length - 2; + opID += upperCamelCase( + keepParentPlural && isParentOfOperations ? nouns[i] : singularize(nouns[i], ignoreSingularizationList) + ); } // singularize final noun, dependent on resource identifier - leave custom nouns alone @@ -90,6 +98,33 @@ function deriveActionVerb(method) { return method.match(CAMEL_CASE)[0]; } +/** + * Checks if a resource identifier is a collection-scoped Operations resource, i.e. the 'operations' + * section is attached to a parent resource collection rather than to a single resource. Applies to both + * the Operations resource collection and a single Operations resource. + * '/groups/{groupId}/clusters/operations' returns true + * '/groups/{groupId}/clusters/operations/{operationId}' returns true + * '/groups/{groupId}/clusters/{clusterName}/operations' returns false + * '/groups/{groupId}/clusters/{clusterName}/operations/{operationId}' returns false + * '/operations' returns false + * + * @param {string} resourceIdentifier the resource identifier to evaluate, without prefix + * @returns {boolean} + */ +function isCollectionScopedOperationsPath(resourceIdentifier) { + const sections = resourceIdentifier.split('/').filter((section) => section.length > 0); + + // a single Operations resource ends with the operation identifier, the resource collection does not + if (sections.length > 0 && isPathParam(sections[sections.length - 1])) { + sections.pop(); + } + + if (sections.length < 2 || sections[sections.length - 1] !== OPERATIONS_SECTION) { + return false; + } + return !isPathParam(sections[sections.length - 2]); +} + function capitalize(val) { return String(val).charAt(0).toUpperCase() + String(val).slice(1); }