Skip to content

Commit 5862b50

Browse files
CLOUDP-297705: POC generate operation ID from method+path
1 parent a1aea1b commit 5862b50

File tree

2 files changed

+267
-0
lines changed

2 files changed

+267
-0
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
import {
3+
generateOperationIdForCustomMethod,
4+
generateOperationIdForStandardMethod,
5+
} from '../../rulesets/functions/utils/generateOperationId.js';
6+
7+
const customMethodCases = [
8+
// Real examples that work well
9+
{
10+
path: '/api/atlas/v2/orgs/{orgId}/resourcePolicies:validate',
11+
expectedOperationId: 'validateOrgResourcePolicies',
12+
},
13+
{
14+
path: '/api/atlas/v2/orgs/{orgId}/invoices/{invoiceId}/lineItems:search',
15+
expectedOperationId: 'searchOrgInvoiceLineItems',
16+
},
17+
{
18+
path: '/api/atlas/v2/groups/{groupId}:migrate',
19+
expectedOperationId: 'migrateGroup',
20+
},
21+
{
22+
path: '/api/atlas/v2/groups/{groupId}/serviceAccounts/{clientId}:invite',
23+
expectedOperationId: 'inviteGroupServiceAccountsClient',
24+
},
25+
{
26+
path: '/api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor/{processorName}:stop',
27+
expectedOperationId: 'stopGroupStreamsTenantProcessor',
28+
},
29+
{
30+
path: '/api/atlas/v2/groups/{groupId}/flexClusters:tenantUpgrade',
31+
expectedOperationId: 'tenantGroupFlexClustersUpgrade',
32+
},
33+
{
34+
path: '/api/atlas/v2/orgs/{orgId}/users/{userId}:addRole',
35+
expectedOperationId: 'addOrgUserRole',
36+
},
37+
// Some examples to show some edge cases
38+
{
39+
path: '/api/atlas/v2/orgs/{orgId}/billing/costExplorer:getUsage',
40+
expectedOperationId: 'getOrgBillingCostExplorerUsage', // Double parent case works well here
41+
},
42+
// Some examples to show some caveats
43+
{
44+
path: '/api/atlas/v2/groups/{groupId}/streams:withSampleConnections',
45+
method: 'get',
46+
expectedOperationId: 'withGroupStreamsSampleConnections', // This one has a weird custom method, ideally it would be /streams:createWithSampleConnections
47+
},
48+
];
49+
50+
const standardMethodCases = [
51+
// Real examples that work well
52+
{
53+
path: '/api/atlas/v2/groups/{groupId}/serviceAccounts',
54+
method: 'list',
55+
expectedOperationId: 'listGroupServiceAccounts',
56+
},
57+
{
58+
path: '/api/atlas/v2/groups/{groupId}/serviceAccounts/{clientId}',
59+
method: 'get',
60+
expectedOperationId: 'getGroupServiceAccountsClient',
61+
},
62+
{
63+
path: '/api/atlas/v2/groups/{groupId}/pushBasedLogExport',
64+
method: 'delete',
65+
expectedOperationId: 'deleteGroupPushBasedLogExport',
66+
},
67+
{
68+
path: '/api/atlas/v2/groups/{groupId}/processes/{processId}/measurements',
69+
method: 'list',
70+
expectedOperationId: 'listGroupProcessMeasurements',
71+
},
72+
// Some examples to show some caveats
73+
{
74+
path: '/api/atlas/v2/groups/{groupId}/serviceAccounts',
75+
method: 'create',
76+
expectedOperationId: 'createGroupServiceAccounts', // Ideally singular instead of plural
77+
},
78+
{
79+
path: '/api/atlas/v2/groups/{groupId}/serverless/{name}',
80+
method: 'delete',
81+
expectedOperationId: 'deleteGroupServerlessName', // Ideally it should be something like /{instanceName} -> deleteGroupServerlessInstance
82+
},
83+
{
84+
path: '/api/atlas/v2/groups/{groupId}/cloudProviderAccess/{cloudProvider}/{roleId}',
85+
method: 'get',
86+
expectedOperationId: 'getGroupCloudRole', // Ideally the provider from cloudProvider wouldn't be stripped here
87+
},
88+
{
89+
path: '/api/atlas/v2/orgs',
90+
method: 'list',
91+
expectedOperationId: 'listOrgs',
92+
},
93+
{
94+
path: '/api/atlas/v2/orgs/{orgId}/apiKeys/{apiUserId}',
95+
method: 'get',
96+
expectedOperationId: 'getOrgApiKeysApiUser', // This one is a bit weird, ideally it would be /apiKeys/{apiKeyId} -> getOrgApiKey
97+
},
98+
{
99+
path: '/api/atlas/v2/groups/{groupId}/privateEndpoint/{cloudProvider}/endpointService/{endpointServiceId}/endpoint/{endpointId}',
100+
method: 'delete',
101+
expectedOperationId: 'deleteGroupPrivateEndpointCloudEndpointServiceEndpoint', // This gets complicated, and ideally for this case cloudProvider wouldn't be stripped to only 'cloud'
102+
},
103+
];
104+
105+
describe('tools/spectral/ipa/rulesets/functions/utils/generateOperationId.js', () => {
106+
for (const testCase of customMethodCases) {
107+
it.concurrent(`Custom method ${testCase.path} gets operationId ${testCase.expectedOperationId}`, () => {
108+
expect(generateOperationIdForCustomMethod(testCase.path)).toBe(testCase.expectedOperationId);
109+
});
110+
}
111+
for (const testCase of standardMethodCases) {
112+
it.concurrent(
113+
`Standard method ${testCase.method} ${testCase.path} gets operationId ${testCase.expectedOperationId}`,
114+
() => {
115+
expect(generateOperationIdForStandardMethod(testCase.path, testCase.method)).toBe(testCase.expectedOperationId);
116+
}
117+
);
118+
}
119+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
const PATH_PREFIX = 'api/atlas/v2/';
2+
const lowerCasePattern = new RegExp('^[a-z]+$');
3+
4+
// Method should be get, delete, update or create
5+
export function generateOperationIdForStandardMethod(path, method) {
6+
let remainingPath = path.split(PATH_PREFIX)[1].split('/');
7+
8+
// Start with the method, for example, 'get' or 'list'
9+
let operationId = method;
10+
11+
// Add the rest of the words from the path
12+
operationId += getOperationIdFromPathSections(remainingPath);
13+
14+
return operationId;
15+
}
16+
17+
export function generateOperationIdForCustomMethod(path) {
18+
const resourcePath = path.split(':')[0];
19+
const customMethodName = path.split(':')[1];
20+
21+
let remainingPath = resourcePath.split(PATH_PREFIX)[1].split('/');
22+
let operationId = '';
23+
24+
// Get custom verb to start the operationId
25+
// invite -> invite
26+
// addNode -> add
27+
let customVerb;
28+
let remainingCustomMethodName = '';
29+
if (lowerCasePattern.test(customMethodName)) {
30+
customVerb = customMethodName;
31+
} else {
32+
customVerb = getFirstWordFromCamelCase(customMethodName);
33+
remainingCustomMethodName = customMethodName.substring(customVerb.length);
34+
}
35+
operationId += customVerb;
36+
37+
operationId += getOperationIdFromPathSections(remainingPath);
38+
39+
// Add any remaining words from the custom name to the end
40+
// /orgs/{orgId}/users/{userId}:addRole -> add + Org + User + Role
41+
operationId += remainingCustomMethodName;
42+
43+
return operationId;
44+
}
45+
46+
function getOperationIdFromPathSections(remainingPath) {
47+
// Get resource names along the path and add to operationId
48+
// /orgs/{orgId}/users/{userId} -> Org + User
49+
// /groups/{groupId}/flexCluster -> Group + FlexCluster
50+
let operationId = '';
51+
while (remainingPath.length > 0) {
52+
const { nextWord, strippedPath } = getWordFromNextResource(remainingPath);
53+
operationId += capitalizeFirstLetter(nextWord);
54+
remainingPath = strippedPath;
55+
}
56+
return operationId;
57+
}
58+
59+
function getWordFromNextResource(pathSections) {
60+
// If there is a parent + child
61+
if (pathSections.length > 1 && !pathSections[0].startsWith('{') && pathSections[1].startsWith('{')) {
62+
const parentResource = pathSections[0];
63+
// If parent ant specifier does not start the same way, return both
64+
// For example ServiceAccounts + Client
65+
const specifier = getResourceNameFromResourceSpecifier(pathSections[1]);
66+
if (!parentResource.startsWith(specifier)) {
67+
const nextWord = parentResource + capitalizeFirstLetter(specifier);
68+
const strippedPath = pathSections.slice(2);
69+
return { nextWord, strippedPath };
70+
}
71+
// If parent and specifier starts the same way, for example org + orgId
72+
// Return only specifier, in this case org
73+
const nextWord = specifier;
74+
const strippedPath = pathSections.slice(2);
75+
return { nextWord, strippedPath };
76+
}
77+
// If next path is a child, strip brackets and return resource name from specifier
78+
if (pathSections[0].startsWith('{')) {
79+
return {
80+
nextWord: getResourceNameFromResourceSpecifier(pathSections[0]),
81+
strippedPath: pathSections.slice(1),
82+
};
83+
}
84+
// Else, just return next word
85+
return {
86+
nextWord: pathSections[0],
87+
strippedPath: pathSections.slice(1),
88+
};
89+
}
90+
91+
/**
92+
* Returns the resource name from a resource specifier.
93+
* For example, '{orgId}' returns 'org', 'apiUserId' returns 'apiUser'
94+
*
95+
* @param resourceSpecifier the resource specifier, including brackets
96+
* @returns {string} the resource name derived from the specifier
97+
*/
98+
function getResourceNameFromResourceSpecifier(resourceSpecifier) {
99+
const strippedFromBrackets = stripStringFromBrackets(resourceSpecifier);
100+
if (lowerCasePattern.test(strippedFromBrackets)) {
101+
return strippedFromBrackets;
102+
}
103+
return removeLastWordFromCamelCase(strippedFromBrackets);
104+
}
105+
106+
/**
107+
* Strips a string from surrounding curly brackets, if there are no brackets the function just returns the string.
108+
*
109+
* @param string the string to remove the curly brackets from
110+
* @returns {string} the string without the brackets
111+
*/
112+
function stripStringFromBrackets(string) {
113+
if (string.startsWith('{') && string.endsWith('}')) {
114+
return string.substring(1, string.length - 1);
115+
}
116+
return string;
117+
}
118+
119+
/**
120+
* Returns the first word from a camelCase string, for example, 'camelCase' returns 'camel'.
121+
*
122+
* @param string the string to get the first word from
123+
* @returns {string} the first word from the passed string
124+
*/
125+
function getFirstWordFromCamelCase(string) {
126+
return string.split(/(?=[A-Z])/)[0];
127+
}
128+
129+
/**
130+
* Removed the last word from a camelCase string, for example, 'camelCaseWord' returns 'camelCase'.
131+
*
132+
* @param string the string to get the first word from
133+
* @returns {string} the first word from the passed string
134+
*/
135+
function removeLastWordFromCamelCase(string) {
136+
const words = string.split(/(?=[A-Z][^A-Z]+$)/);
137+
return words.slice(0, words.length - 1).join();
138+
}
139+
140+
/**
141+
* Capitalizes the first letter in a string.
142+
*
143+
* @param string
144+
* @returns {string}
145+
*/
146+
function capitalizeFirstLetter(string) {
147+
return string.charAt(0).toUpperCase() + string.slice(1);
148+
}

0 commit comments

Comments
 (0)