From 0a420f00ca82604fd48a8fe1c23f1de8b79c33a6 Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Thu, 30 Jul 2026 15:57:33 +0100 Subject: [PATCH 1/2] fix(ipa): preserve resource scope in operation IDs for trailing /operations paths A collection-scoped Operations resource and an instance-scoped Operations resource under the same parent produced the same operation ID, because generateOperationID removes path parameters before building the noun list and then singularizes every non-final noun. Keep the parent noun in plural form when the trailing 'operations' section is attached to a resource collection, so the two resources get distinct IDs: /groups/{groupId}/clusters/operations -> listGroupClustersOperations /groups/{groupId}/clusters/{clusterName}/operations -> listGroupClusterOperations The change is gated on a trailing 'operations' section with a non-parameter parent, so no other operation IDs are affected. --- .../utils/operationIdGeneration.test.js | 29 +++++++++++++++++++ tools/spectral/ipa/rulesets/IPA-105.yaml | 1 + tools/spectral/ipa/rulesets/README.md | 1 + .../functions/utils/operationIdGeneration.js | 28 +++++++++++++++++- 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js index b6c0522412..3053454723 100644 --- a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js +++ b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js @@ -29,6 +29,35 @@ 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 trailing operations paths', () => { + // the collection-scoped path keeps the parent plural, the instance-scoped path keeps 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('list', '/api/atlas/v2/groups/{groupId}/clusters/operations')).not.toEqual( + generateOperationID('list', '/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations') + ); + }); + + 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 section + expect(generateOperationID('get', '/api/atlas/v2/groups/{groupId}/clusters/operations/{operationId}')).toEqual( + 'getGroupClusterOperation' + ); + // 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 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-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..3e782f7bf8 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -242,6 +242,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..94ea5cc860 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,24 @@ function deriveActionVerb(method) { return method.match(CAMEL_CASE)[0]; } +/** + * Checks if a resource identifier is a collection-scoped Operations resource, i.e. the trailing + * 'operations' section is attached to a parent resource collection rather than to a single resource. + * '/groups/{groupId}/clusters/operations' returns true + * '/groups/{groupId}/clusters/{clusterName}/operations' 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); + 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); } From cdaaa0df09d456b30bbb6444dbc09bdf9e123451 Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Thu, 30 Jul 2026 16:16:49 +0100 Subject: [PATCH 2/2] fix(ipa): preserve resource scope for single Operations resource operation IDs The sibling item-level Operations paths collided for the same reason as the resource collection paths: path parameters are removed before the noun list is built, so both reduced to the same nouns and the parent was singularized in both cases. Extend isCollectionScopedOperationsPath to drop a trailing path parameter before the existing structural check, so the same collection- versus instance-scope distinction applies to a single Operations resource: /groups/{groupId}/clusters/operations/{operationId} -> getGroupClustersOperation /groups/{groupId}/clusters/{clusterName}/operations/{operationId} -> getGroupClusterOperation Document the exception in the IPA-104 valid-operation-id description, which governs the Get method on a single resource. --- .../utils/operationIdGeneration.test.js | 41 ++++++++++++++++--- tools/spectral/ipa/rulesets/IPA-104.yaml | 1 + tools/spectral/ipa/rulesets/README.md | 1 + .../functions/utils/operationIdGeneration.js | 13 +++++- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js index 3053454723..22310e53c4 100644 --- a/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js +++ b/tools/spectral/ipa/__tests__/utils/operationIdGeneration.test.js @@ -29,8 +29,8 @@ 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 trailing operations paths', () => { - // the collection-scoped path keeps the parent plural, the instance-scoped path keeps it singular, + 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' @@ -38,17 +38,31 @@ describe('tools/spectral/ipa/utils/operationIdGeneration.js', () => { 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 section - expect(generateOperationID('get', '/api/atlas/v2/groups/{groupId}/clusters/operations/{operationId}')).toEqual( - 'getGroupClusterOperation' + // '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'); @@ -58,6 +72,23 @@ describe('tools/spectral/ipa/utils/operationIdGeneration.js', () => { ); }); + 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/README.md b/tools/spectral/ipa/rulesets/README.md index 3e782f7bf8..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: diff --git a/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js b/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js index 94ea5cc860..3796074faf 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js +++ b/tools/spectral/ipa/rulesets/functions/utils/operationIdGeneration.js @@ -99,10 +99,13 @@ function deriveActionVerb(method) { } /** - * Checks if a resource identifier is a collection-scoped Operations resource, i.e. the trailing - * 'operations' section is attached to a parent resource collection rather than to a single resource. + * 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 @@ -110,6 +113,12 @@ function deriveActionVerb(method) { */ 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; }