diff --git a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js index b6c05224128..127359e644f 100644 --- a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js +++ b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js @@ -29,6 +29,80 @@ 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 preserve the resource scope for mutating methods on operations paths', () => { + // IPA-132 Operations resources are read-only, so these methods should not exist. Pinned to + // ensure the scope distinction stays consistent across every method, not just Get and List. + expect(generateOperationID('create', '/api/atlas/v2/groups/{groupId}/clusters/operations')).toEqual( + 'createGroupClustersOperation' + ); + expect(generateOperationID('create', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations')).toEqual( + 'createGroupClusterOperation' + ); + expect(generateOperationID('update', '/api/atlas/v2/groups/{groupId}/clusters/operations/{operationId}')).toEqual( + 'updateGroupClustersOperation' + ); + expect( + generateOperationID('update', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations/{operationId}') + ).toEqual('updateGroupClusterOperation'); + expect(generateOperationID('delete', '/api/atlas/v2/groups/{groupId}/clusters/operations/{operationId}')).toEqual( + 'deleteGroupClustersOperation' + ); + expect( + generateOperationID('delete', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations/{operationId}') + ).toEqual('deleteGroupClusterOperation'); + }); + + it('should not preserve the parent plural for other operations paths', () => { + // {groupId} makes the parent instance-scoped (a single group), so the existing singularization applies + expect(generateOperationID('list', '/api/atlas/v2/groups/{groupId}/operations')).toEqual('listGroupOperations'); + // the parent resource collection itself (no ID param), so it is treated as collection-scoped + expect(generateOperationID('list', '/api/atlas/v2/groups/operations')).toEqual('listGroupsOperations'); + // '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 171d932a02d..f13f83baff7 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 '/resources/operations/{operationId}', the parent noun (e.g. 'resources') stays in its plural form instead of being singularized. ##### 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 55efd0a485b..5622c702769 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 '/resources/operations', the parent noun (e.g. 'resources') stays in its plural form instead of being singularized. ##### 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 f5a49579686..a4e0fcf4f07 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 '/resources/operations/{operationId}', the parent noun (e.g. 'resources') stays in its plural form instead of being singularized. ##### 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 '/resources/operations', the parent noun (e.g. 'resources') stays in its plural form instead of being singularized. ##### 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 d247caf5ff8..3796074faf3 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); }