Skip to content

CLOUDP-297705: POC generate operation ID from method+path #407

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
1,147 changes: 897 additions & 250 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -14,6 +14,9 @@
"transform": {
"^.+\\.[t|j]sx?$": "babel-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!ember-inflector)"
],
"testPathIgnorePatterns": [
"__helpers__",
"metrics/data"
@@ -26,9 +29,12 @@
"@stoplight/spectral-functions": "^1.9.3",
"@stoplight/spectral-ref-resolver": "^1.0.5",
"@stoplight/spectral-ruleset-bundler": "^1.6.1",
"active-inflector": "^0.1.0",
"apache-arrow": "^19.0.1",
"dotenv": "^16.4.7",
"ember-inflector": "^5.0.2",
"eslint-plugin-jest": "^28.10.0",
"inflector-js": "^1.0.1",
"markdown-table": "^3.0.4",
"openapi-to-postmanv2": "4.25.0",
"parquet-wasm": "^0.6.1"
134 changes: 134 additions & 0 deletions tools/spectral/ipa/__tests__/utils/generateOperationId.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, it } from '@jest/globals';
import {
generateOperationIdForCustomMethod,
generateOperationIdForStandardMethod,
} from '../../rulesets/functions/utils/generateOperationId.js';

const customMethodCases = [
// Real examples that work well
{
path: '/api/atlas/v2/orgs/{orgId}/resourcePolicies:validate',
expectedOperationId: 'validateOrgResourcePolicies',
},
{
path: '/api/atlas/v2/orgs/{orgId}/invoices/{invoiceId}/lineItems:search',
expectedOperationId: 'searchOrgInvoiceLineItems',
},
{
path: '/api/atlas/v2/groups/{groupId}:migrate',
expectedOperationId: 'migrateGroup',
},
{
path: '/api/atlas/v2/groups/{groupId}/serviceAccounts/{clientId}:invite',
expectedOperationId: 'inviteGroupServiceAccountsClient',
},
{
path: '/api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor/{processorName}:stop',
expectedOperationId: 'stopGroupStreamsTenantProcessor',
},
{
path: '/api/atlas/v2/groups/{groupId}/flexClusters:tenantUpgrade',
expectedOperationId: 'tenantGroupFlexClustersUpgrade',
},
{
path: '/api/atlas/v2/orgs/{orgId}/users/{userId}:addRole',
expectedOperationId: 'addOrgUserRole',
},
// Some examples to show some edge cases
{
path: '/api/atlas/v2/orgs/{orgId}/billing/costExplorer:getUsage',
expectedOperationId: 'getOrgBillingCostExplorerUsage', // Double parent case works well here
},
// Some examples to show some caveats
{
path: '/api/atlas/v2/groups/{groupId}/streams:withSampleConnections',
method: 'get',
expectedOperationId: 'withGroupStreamsSampleConnections', // This one has a weird custom method, ideally it would be /streams:createWithSampleConnections
},
];

const standardMethodCases = [
// Real examples that work well
{
path: '/api/atlas/v2/groups/{groupId}/serviceAccounts',
method: 'list',
expectedOperationId: 'listGroupServiceAccounts',
},
{
path: '/api/atlas/v2/groups/{groupId}/serviceAccounts/{clientId}',
method: 'get',
expectedOperationId: 'getGroupServiceAccountsClient',
},
{
path: '/api/atlas/v2/groups/{groupId}/pushBasedLogExport',
method: 'delete',
expectedOperationId: 'deleteGroupPushBasedLogExport',
},
{
path: '/api/atlas/v2/groups/{groupId}/processes/{processId}/measurements',
method: 'list',
expectedOperationId: 'listGroupProcessMeasurements',
},
// Some examples to show some caveats
{
path: '/api/atlas/v2/groups/{groupId}/serviceAccounts',
method: 'create',
expectedOperationId: 'createGroupServiceAccounts', // Ideally singular instead of plural
},
{
path: '/api/atlas/v2/groups/{groupId}/invites/{invitationId}',
method: 'get',
expectedOperationId: 'getGroupInvitation',
},
{
path: '/api/atlas/v2/federationSettings/{federationSettingsId}/connectedOrgConfigs/{orgId}/roleMappings/{id}',
method: 'delete',
expectedOperationId: 'deleteFederationSettingsConnectedOrgConfigsOrgRoleMappings',
},
{
path: '/api/atlas/v2/groups/{groupId}/clusters/{hostName}/logs/{logName}.gz',
method: 'get',
expectedOperationId: 'getGroupClustersHostLog',
},
{
path: '/api/atlas/v2/groups/{groupId}/serverless/{name}',
method: 'delete',
expectedOperationId: 'deleteGroupServerless', // Ideally it should be something like /{instanceName} -> deleteGroupServerlessInstance
},
{
path: '/api/atlas/v2/groups/{groupId}/cloudProviderAccess/{cloudProvider}/{roleId}',
method: 'get',
expectedOperationId: 'getGroupCloudRole', // Ideally the provider from cloudProvider wouldn't be stripped here
},
{
path: '/api/atlas/v2/orgs',
method: 'list',
expectedOperationId: 'listOrgs',
},
{
path: '/api/atlas/v2/orgs/{orgId}/apiKeys/{apiUserId}',
method: 'get',
expectedOperationId: 'getOrgApiKeysApiUser', // This one is a bit weird, ideally it would be /apiKeys/{apiKeyId} -> getOrgApiKey
},
{
path: '/api/atlas/v2/groups/{groupId}/privateEndpoint/{cloudProvider}/endpointService/{endpointServiceId}/endpoint/{endpointId}',
method: 'delete',
expectedOperationId: 'deleteGroupPrivateEndpointCloudEndpointServiceEndpoint', // This gets complicated, and ideally for this case cloudProvider wouldn't be stripped to only 'cloud'
},
];

describe('tools/spectral/ipa/rulesets/functions/utils/generateOperationId.js', () => {
for (const testCase of customMethodCases) {
it.concurrent(`Custom method ${testCase.path} gets operationId ${testCase.expectedOperationId}`, () => {
expect(generateOperationIdForCustomMethod(testCase.path)).toBe(testCase.expectedOperationId);
});
}
for (const testCase of standardMethodCases) {
it.concurrent(
`Standard method ${testCase.method} ${testCase.path} gets operationId ${testCase.expectedOperationId}`,
() => {
expect(generateOperationIdForStandardMethod(testCase.path, testCase.method)).toBe(testCase.expectedOperationId);
}
);
}
});
21 changes: 21 additions & 0 deletions tools/spectral/ipa/rulesets/IPA-XXX.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Operation ID

functions:
- operationIdFormat
- operationIdFormatInflector

rules:
xgen-IPA-XXX-operation-id-format:
description: 'Operation IDs must follow the IPA guidelines'
message: '{{error}}'
severity: error
given: '$..operationId'
then:
function: 'operationIdFormat'
xgen-IPA-XXX-operation-id-format-inflector:
description: 'Operation IDs must follow the IPA guidelines'
message: '{{error}}'
severity: error
given: '$..operationId'
then:
function: 'operationIdFormatInflector'
79 changes: 79 additions & 0 deletions tools/spectral/ipa/rulesets/functions/operationIdFormat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
getResourcePathItems,
isSingleResourceIdentifier,
isCustomMethodIdentifier,
isSingletonResource,
} from './utils/resourceEvaluation.js';
import {
generateOperationIdForCustomMethod,
generateOperationIdForStandardMethod,
} from './utils/generateOperationId.js';

const BASE_PATH = '/api/atlas/v2';

export default (input, _, { path, documentInventory }) => {
const operationId = input;
const oas = documentInventory.resolved;
const operationPath = path[1];
const method = path[2];
const resourcePathItems = getResourcePathItems(operationPath, oas.paths);

if (operationPath === BASE_PATH) {
const expectedOperationId = 'getSystemStatus';
if (operationId !== expectedOperationId) {
return [
{
message: `Invalid operation ID ${operationId}, please change to ${expectedOperationId}`,
},
];
}
return;
}

if (isCustomMethodIdentifier(operationPath)) {
const expectedOperationId = generateOperationIdForCustomMethod(operationPath);
if (operationId !== expectedOperationId) {
return [
{
message: `Invalid operation ID ${operationId}, please change to ${expectedOperationId}`,
},
];
}
return;
}

let expectedOperationId = '';
switch (method) {
case 'get':
if (isSingleResourceIdentifier(operationPath) || isSingletonResource(resourcePathItems)) {
expectedOperationId = generateOperationIdForStandardMethod(operationPath, 'get');
} else {
expectedOperationId = generateOperationIdForStandardMethod(operationPath, 'list');
}
break;
case 'post':
expectedOperationId = generateOperationIdForStandardMethod(operationPath, 'create');
break;
case 'patch':
expectedOperationId = generateOperationIdForStandardMethod(operationPath, 'update');
break;
case 'put':
expectedOperationId = generateOperationIdForStandardMethod(operationPath, 'update');
break;
case 'delete':
expectedOperationId = generateOperationIdForStandardMethod(operationPath, 'delete');
break;
}
if (!expectedOperationId) {
console.error('Unsupported http method');
return;
}

if (operationId !== expectedOperationId) {
return [
{
message: `Invalid operation ID ${operationId}, please change to ${expectedOperationId}`,
},
];
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
getResourcePathItems,
isSingleResourceIdentifier,
isCustomMethodIdentifier,
isSingletonResource,
isResourceCollectionIdentifier,
} from './utils/resourceEvaluation.js';
import {
generateOperationIdForCustomMethod_inflector,
generateOperationIdForStandardMethod_inflector,
} from './utils/generateOperationId.js';

const BASE_PATH = '/api/atlas/v2';

export default (input, _, { path, documentInventory }) => {
const operationId = input;
const oas = documentInventory.resolved;
const operationPath = path[1];
const method = path[2];
const resourcePathItems = getResourcePathItems(operationPath, oas.paths);

if (operationPath === BASE_PATH) {
const expectedOperationId = 'getSystemStatus';
if (operationId !== expectedOperationId) {
return [
{
message: `Invalid operation ID ${operationId}, please change to ${expectedOperationId}`,
},
];
}
return;
}

if (isCustomMethodIdentifier(operationPath)) {
const expectedOperationId = generateOperationIdForCustomMethod_inflector(operationPath);
if (operationId !== expectedOperationId) {
return [
{
message: `Invalid operation ID ${operationId}, please change to ${expectedOperationId}`,
},
];
}
return;
}

let expectedOperationId = '';
switch (method) {
case 'get':
if (isResourceCollectionIdentifier(operationPath)) {
if (isSingletonResource(resourcePathItems)) {
expectedOperationId = generateOperationIdForStandardMethod_inflector(operationPath, 'get', false);
} else {
expectedOperationId = generateOperationIdForStandardMethod_inflector(operationPath, 'list', false);
}
} else {
expectedOperationId = generateOperationIdForStandardMethod_inflector(
operationPath,
'get',
isSingleResourceIdentifier(operationPath)
);
}
break;
case 'post':
expectedOperationId = generateOperationIdForStandardMethod_inflector(operationPath, 'create', true);
break;
case 'patch':
expectedOperationId = generateOperationIdForStandardMethod_inflector(
operationPath,
'update',
!isSingletonResource(resourcePathItems)
);
break;
case 'put':
expectedOperationId = generateOperationIdForStandardMethod_inflector(operationPath, 'update', true);
break;
case 'delete':
expectedOperationId = generateOperationIdForStandardMethod_inflector(operationPath, 'delete', true);
break;
}
if (!expectedOperationId) {
console.error('Unsupported http method');
return;
}

if (operationId !== expectedOperationId) {
return [
{
message: `Invalid operation ID ${operationId}, please change to ${expectedOperationId}`,
},
];
}
};
271 changes: 271 additions & 0 deletions tools/spectral/ipa/rulesets/functions/utils/generateOperationId.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
//import { singularize } from 'active-inflector'; // No "exports" main defined in /node_modules/active-inflector/package.json
import {singularize} from 'ember-inflector';
//import Inflector from 'inflector-js';

const PATH_PREFIX = '/api/atlas/v2/';
const PATH_UNAUTH_PREFIX = '/api/atlas/v2/unauth/';
const lowerCasePattern = new RegExp('^[a-z]+$');

// Method should be get, delete, update or create
export function generateOperationIdForStandardMethod(path, method) {
let remainingPath = removePathPrefix(path).split('/');

// Start with the method, for example, 'get' or 'list'
let operationId = method;

// Add the rest of the words from the path
operationId += getOperationIdFromPathSections(remainingPath);

return operationId;
}

export function generateOperationIdForCustomMethod(path) {
const resourcePath = path.split(':')[0];
const customMethodName = path.split(':')[1];

let remainingPath = removePathPrefix(resourcePath).split('/');
let operationId = '';

// Get custom verb to start the operationId
// invite -> invite
// addNode -> add
let customVerb;
let remainingCustomMethodName = '';
if (lowerCasePattern.test(customMethodName)) {
customVerb = customMethodName;
} else {
customVerb = getFirstWordFromCamelCase(customMethodName);
remainingCustomMethodName = customMethodName.substring(customVerb.length);
}
operationId += customVerb;

operationId += getOperationIdFromPathSections(remainingPath);

// Add any remaining words from the custom name to the end
// /orgs/{orgId}/users/{userId}:addRole -> add + Org + User + Role
operationId += remainingCustomMethodName;

return operationId;
}

// Method should be get, delete, update or create
export function generateOperationIdForStandardMethod_inflector(path, method, transformLastWordToSingular) {
let remainingPath = removePathPrefix(path).split('/');

// Start with the method, for example, 'get' or 'list'
let operationId = method;

// Add the rest of the words from the path
operationId += getOperationIdFromPathSections_inflector(remainingPath, transformLastWordToSingular);

return operationId;
}

export function generateOperationIdForCustomMethod_inflector(path) {
const resourcePath = path.split(':')[0];
const customMethodName = path.split(':')[1];

let remainingPath = removePathPrefix(resourcePath).split('/');
let operationId = '';

// Get custom verb to start the operationId
// invite -> invite
// addNode -> add
let customVerb;
let remainingCustomMethodName = '';
if (lowerCasePattern.test(customMethodName)) {
customVerb = customMethodName;
} else {
customVerb = getFirstWordFromCamelCase(customMethodName);
remainingCustomMethodName = customMethodName.substring(customVerb.length);
}
operationId += customVerb;

operationId += getOperationIdFromPathSections_inflector(remainingPath, resourcePath.endsWith('}'));

// Add any remaining words from the custom name to the end
// /orgs/{orgId}/users/{userId}:addRole -> add + Org + User + Role
operationId += remainingCustomMethodName;

return operationId;
}

function getOperationIdFromPathSections_inflector(resourcePathSections, transformLastWordToSingular) {
// lastWordIsPlural: true -> for collection custom and list
// lastWordIsPlural: false -> for singular custom and create, get, update, delete

// /orgs/{orgId}/serviceAccounts
// /orgs/{orgId}/serviceAccounts/{clientId}
// createOrgServiceAccount (singular + singular)
// listOrgServiceAccounts (singular + plural)
// getOrgServiceAccount (singular + singular)
// updateOrgServiceAccount (singular + singular)
// deleteOrgServiceAccount (singular + singular)

// /orgs/{orgId}/serviceAccounts/{clientId}:invite
// inviteOrgServiceAccount (singular + singular)

// /orgs/{orgId}/serviceAccounts:search
// searchOrgServiceAccounts (singular + plural)

let operationId = '';
resourcePathSections.forEach((pathSection, index) => {
if (!pathSection.startsWith('{')) {
if (index === resourcePathSections.length - 1) {
if (transformLastWordToSingular) {
operationId += capitalizeFirstLetter(singularizeCamelCase(pathSection));
} else {
operationId += capitalizeFirstLetter(pathSection);
}
} else {
operationId += capitalizeFirstLetter(singularizeCamelCase(pathSection));
}
}
});
return operationId;
}

function singularizeCamelCase(string) {
const words = getWordsFromCamelCase(string);

const lastWord = singularize(words[words.length - 1]);
return words.slice(0, words.length - 1).join() + capitalizeFirstLetter(lastWord);
}

function getOperationIdFromPathSections(remainingPath) {
// Get resource names along the path and add to operationId
// /orgs/{orgId}/users/{userId} -> Org + User
// /groups/{groupId}/flexCluster -> Group + FlexCluster
let operationId = '';
while (remainingPath.length > 0) {
const { nextWord, strippedPath } = getWordFromNextResource(remainingPath);
operationId += capitalizeFirstLetter(nextWord);
remainingPath = strippedPath;
}
return operationId;
}

function getWordFromNextResource(pathSections) {
// If there is a parent + child
if (pathSections.length > 1 && !pathSections[0].startsWith('{') && pathSections[1].startsWith('{')) {
const parentResource = pathSections[0];
// If parent ant specifier does not start the same way, return both
// For example ServiceAccounts + Client
const specifier = getResourceNameFromResourceSpecifier(pathSections[1]);
if (specifier === 'id' || specifier === 'hostname' || specifier === 'username' || specifier === 'name') {
const strippedPath = pathSections.slice(2);
return { nextWord: parentResource, strippedPath };
}
if (!parentResource.startsWith(specifier)) {
if (specifier.endsWith('ation') && parentResource.startsWith(specifier.substring(0, specifier.length - 5))) {
const nextWord = specifier;
const strippedPath = pathSections.slice(2);
return { nextWord, strippedPath };
}
const nextWord = parentResource + capitalizeFirstLetter(specifier);
const strippedPath = pathSections.slice(2);
return { nextWord, strippedPath };
}
// If parent and specifier starts the same way, for example org + orgId
// Return only specifier, in this case org
const nextWord = specifier;
const strippedPath = pathSections.slice(2);
return { nextWord, strippedPath };
}
// If next path is a child, strip brackets and return resource name from specifier
if (pathSections[0].startsWith('{')) {
return {
nextWord: getResourceNameFromResourceSpecifier(pathSections[0]),
strippedPath: pathSections.slice(1),
};
}
// Else, just return next word
return {
nextWord: pathSections[0],
strippedPath: pathSections.slice(1),
};
}

/**
* Returns the resource name from a resource specifier.
* For example, '{orgId}' returns 'org', 'apiUserId' returns 'apiUser', '{logName}.gz' returns 'log'
*
* @param resourceSpecifier the resource specifier, including brackets
* @returns {string} the resource name derived from the specifier
*/
function getResourceNameFromResourceSpecifier(resourceSpecifier) {
let string = resourceSpecifier;
if (resourceSpecifier.includes('.')) {
string = resourceSpecifier.split('.')[0];
}
const strippedFromBrackets = stripStringFromBrackets(string);
if (lowerCasePattern.test(strippedFromBrackets)) {
return strippedFromBrackets;
}
return removeLastWordFromCamelCase(strippedFromBrackets);
}

/**
* Strips a string from surrounding curly brackets, if there are no brackets the function just returns the string.
*
* @param string the string to remove the curly brackets from
* @returns {string} the string without the brackets
*/
function stripStringFromBrackets(string) {
if (string.startsWith('{') && string.endsWith('}')) {
return string.substring(1, string.length - 1);
}
return string;
}

/**
* Returns the first word from a camelCase string, for example, 'camelCase' returns 'camel'.
*
* @param string the string to get the first word from
* @returns {string} the first word from the passed string
*/
function getFirstWordFromCamelCase(string) {
return string.split(/(?=[A-Z])/)[0];
}

/**
* Removed the last word from a camelCase string, for example, 'camelCaseWord' returns 'camelCase'.
*
* @param string the string to get the first word from
* @returns {string} the the passed string without the last word
*/
function removeLastWordFromCamelCase(string) {
const words = string.split(/(?=[A-Z][^A-Z]+$)/);
return words.slice(0, words.length - 1).join();
}

/**
* Get the words in camel case string as an array of words
*
* @param string the camel case string
* @returns {string[]} the words split into an array
*/
function getWordsFromCamelCase(string) {
return string.split(/(?=[A-Z][^A-Z]+$)/);
}

/**
* Capitalizes the first letter in a string.
*
* @param string
* @returns {string}
*/
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}

function removePathPrefix(path) {
if (path.startsWith(PATH_UNAUTH_PREFIX)) {
return path.split(PATH_UNAUTH_PREFIX)[1];
} else if (path.startsWith(PATH_PREFIX)) {
return path.split(PATH_PREFIX)[1];
} else {
console.error('There is another prefix', path);
return path;
}
}