diff --git a/tools/spectral/ipa/__tests__/IPA102EachPathAlternatesBetweenResourceNameAndPathParam.test.js b/tools/spectral/ipa/__tests__/IPA102EachPathAlternatesBetweenResourceNameAndPathParam.test.js index 9bb8a2cc91..45b105fcee 100644 --- a/tools/spectral/ipa/__tests__/IPA102EachPathAlternatesBetweenResourceNameAndPathParam.test.js +++ b/tools/spectral/ipa/__tests__/IPA102EachPathAlternatesBetweenResourceNameAndPathParam.test.js @@ -32,6 +32,24 @@ testRule('xgen-IPA-102-path-alternate-resource-name-path-param', [ }, errors: [], }, + { + name: 'valid paths - IPA-132 Operations endpoints', + document: { + paths: { + // Collection-scoped Operations endpoints, nested directly under the parent collection + '/api/atlas/v2/resourceName/operations': {}, + '/api/atlas/v2/resourceName/operations/{operationId}': {}, + '/api/atlas/v2/resourceName1/{pathParam}/resourceName2/operations': {}, + '/api/atlas/v2/resourceName1/{pathParam}/resourceName2/operations/{operationId}': {}, + '/api/atlas/v2/unauth/resourceName/operations': {}, + '/api/atlas/v2/unauth/resourceName/operations/{operationId}': {}, + // Instance-scoped Operations endpoints, nested under the parent resource instance + '/api/atlas/v2/resourceName/{pathParam}/operations': {}, + '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': {}, + }, + }, + errors: [], + }, { name: 'invalid paths - api/atlas/v2', document: { @@ -134,6 +152,69 @@ testRule('xgen-IPA-102-path-alternate-resource-name-path-param', [ }, ], }, + { + name: 'invalid paths - only a trailing Operations suffix is exempt', + document: { + paths: { + // `operations` is only exempt as the final segment, optionally followed by its path param + '/api/atlas/v2/resourceName/operations/foo': {}, + '/api/atlas/v2/resourceName/operations/{operationId}/details': {}, + }, + }, + errors: [ + { + code: 'xgen-IPA-102-path-alternate-resource-name-path-param', + message: 'API paths must alternate between resource name and path params.', + path: ['paths', '/api/atlas/v2/resourceName/operations/foo'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-102-path-alternate-resource-name-path-param', + message: 'API paths must alternate between resource name and path params.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/details'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'Operations endpoints no longer need an exception', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-102-path-alternate-resource-name-path-param': 'reason', + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-102-path-alternate-resource-name-path-param', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations', + 'x-xgen-IPA-exception', + 'xgen-IPA-102-path-alternate-resource-name-path-param', + ], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'Operations paths which are still violations may keep an exception', + document: { + paths: { + // `operations` is not the final segment, so the path is a violation the exception suppresses + '/api/atlas/v2/resourceName/operations/foo': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-102-path-alternate-resource-name-path-param': 'reason', + }, + }, + }, + }, + errors: [], + }, { name: 'invalid paths with exceptions', document: { diff --git a/tools/spectral/ipa/rulesets/IPA-102.yaml b/tools/spectral/ipa/rulesets/IPA-102.yaml index d573295097..dd512191e9 100644 --- a/tools/spectral/ipa/rulesets/IPA-102.yaml +++ b/tools/spectral/ipa/rulesets/IPA-102.yaml @@ -38,6 +38,8 @@ rules: - Paths must follow a pattern where resource names and path parameters strictly alternate - Even-indexed path segments should be resource names (not path parameters) - Odd-indexed path segments should be path parameters + - A trailing `operations` or `operations/{operationId}` suffix is exempt, for the Operations + endpoints defined by IPA-132 - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation - If any parent path has an exception for this rule, the exception will be inherited. diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 43ba12b55d..f5a4957968 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -59,6 +59,8 @@ Rule checks for the following conditions: - Paths must follow a pattern where resource names and path parameters strictly alternate - Even-indexed path segments should be resource names (not path parameters) - Odd-indexed path segments should be path parameters + - A trailing `operations` or `operations/{operationId}` suffix is exempt, for the Operations + endpoints defined by IPA-132 - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation - If any parent path has an exception for this rule, the exception will be inherited. diff --git a/tools/spectral/ipa/rulesets/functions/IPA102EachPathAlternatesBetweenResourceNameAndPathParam.js b/tools/spectral/ipa/rulesets/functions/IPA102EachPathAlternatesBetweenResourceNameAndPathParam.js index 71f5b5acb7..db28e96f1a 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA102EachPathAlternatesBetweenResourceNameAndPathParam.js +++ b/tools/spectral/ipa/rulesets/functions/IPA102EachPathAlternatesBetweenResourceNameAndPathParam.js @@ -15,8 +15,35 @@ const getPrefix = (path) => { return null; }; +const OPERATIONS_SEGMENT = 'operations'; + +/** + * Removes a trailing Operations suffix (`operations` or `operations/{operationId}`) so the rest of + * the path can be checked for strict alternation. The Operations collection defined by IPA-132 is + * nested directly under its parent resource and intentionally does not alternate. + * + * @param {string[]} elements - The path segments + * @returns {string[]} The path segments without a trailing Operations suffix + */ +const stripOperationsSuffix = (elements) => { + const last = elements[elements.length - 1]; + const secondToLast = elements[elements.length - 2]; + + let suffixLength; + if (secondToLast === OPERATIONS_SEGMENT && isPathParam(last)) { + suffixLength = 2; // `.../operations/{operationId}` + } else if (last === OPERATIONS_SEGMENT) { + suffixLength = 1; // `.../operations` + } else { + return elements; + } + + // An unscoped `/api/atlas/v2/operations` strips to nothing and passes; rejecting it is IPA-132's job. + return elements.slice(0, elements.length - suffixLength); +}; + const validatePathStructure = (elements) => { - return elements.every((element, index) => { + return stripOperationsSuffix(elements).every((element, index) => { const isEvenIndex = index % 2 === 0; return isEvenIndex ? !isPathParam(element) : isPathParam(element); });