Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions tools/spectral/ipa/rulesets/IPA-104.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions tools/spectral/ipa/rulesets/IPA-105.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions tools/spectral/ipa/rulesets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
Loading