From d4c6038d4448609fad535d72a33f2c3a679156f5 Mon Sep 17 00:00:00 2001 From: CountRedClaw Date: Thu, 11 Sep 2025 17:11:46 +0500 Subject: [PATCH 01/14] feat: path change due to moving a prefix from server.url to path classified as annotation --- src/openapi/openapi3.classify.ts | 10 ++++----- src/openapi/openapi3.mapping.ts | 38 ++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/openapi/openapi3.classify.ts b/src/openapi/openapi3.classify.ts index f65c3ea..c2c7dd9 100644 --- a/src/openapi/openapi3.classify.ts +++ b/src/openapi/openapi3.classify.ts @@ -5,8 +5,8 @@ import { breakingIfAfterTrue, nonBreaking, PARENT_JUMP, - strictResolveValueFromContext, reverseClassifyRule, + strictResolveValueFromContext, transformClassifyRule, unclassified, } from '../core' @@ -14,7 +14,7 @@ import { getKeyValue, isExist, isNotEmptyArray } from '../utils' import { emptySecurity, includeSecurity } from './openapi3.utils' import type { ClassifyRule, CompareContext } from '../types' import { DiffType } from '../types' -import { hidePathParamNames } from './openapi3.mapping' +import { unifyPath } from './openapi3.mapping' export const paramClassifyRule: ClassifyRule = [ ({ after }) => { @@ -143,9 +143,9 @@ export const pathChangeClassifyRule: ClassifyRule = [ ({ before, after }) => { const beforePath = before.key as string const afterPath = after.key as string - const unifiedBeforePath = hidePathParamNames(beforePath) - const unifiedAfterPath = hidePathParamNames(afterPath) - + const unifiedBeforePath = unifyPath(before)(beforePath) + const unifiedAfterPath = unifyPath(after)(afterPath) + // If unified paths are the same, it means only parameter names changed return unifiedBeforePath === unifiedAfterPath ? annotation : breaking } diff --git a/src/openapi/openapi3.mapping.ts b/src/openapi/openapi3.mapping.ts index 2587250..d760c3a 100644 --- a/src/openapi/openapi3.mapping.ts +++ b/src/openapi/openapi3.mapping.ts @@ -1,6 +1,7 @@ -import type { MapKeysResult, MappingResolver } from '../types' +import { MapKeysResult, MappingResolver, NodeContext } from '../types' import { getStringValue, objectKeys, onlyExistedArrayIndexes } from '../utils' import { mapPathParams } from './openapi3.utils' +import { OpenAPIV3 } from 'openapi-types' export const singleOperationPathMappingResolver: MappingResolver = (before, after) => { @@ -23,18 +24,18 @@ export const singleOperationPathMappingResolver: MappingResolver = (befo return result } -export const pathMappingResolver: MappingResolver = (before, after) => { +export const pathMappingResolver: MappingResolver = (before, after, ctx) => { const result: MapKeysResult = { added: [], removed: [], mapped: {} } const originalBeforeKeys = objectKeys(before) const originalAfterKeys = objectKeys(after) - const unifiedAfterKeys = originalAfterKeys.map(hidePathParamNames) + const unifiedAfterKeys = originalAfterKeys.map(unifyPath(ctx.after)) const notMappedAfterIndices = new Set(originalAfterKeys.keys()) originalBeforeKeys.forEach(beforeKey => { - const unifiedBeforePath = hidePathParamNames(beforeKey) + const unifiedBeforePath = unifyPath(ctx.before)(beforeKey) const index = unifiedAfterKeys.indexOf(unifiedBeforePath) if (index < 0) { @@ -175,6 +176,35 @@ function isWildcardCompatible(beforeType: string, afterType: string): boolean { return true } +// todo copy-paste from api-processor +export const extractOperationBasePath = (servers?: OpenAPIV3.ServerObject[]): string => { + if (!Array.isArray(servers) || !servers.length) { return '' } + + try { + const [firstServer] = servers + let serverUrl = firstServer.url + const { variables = {} } = firstServer + + for (const param of Object.keys(variables)) { + serverUrl = serverUrl.replace(new RegExp(`{${param}}`, 'g'), variables[param].default) + } + + const { pathname } = new URL(serverUrl, 'https://localhost') + return pathname.slice(-1) === '/' ? pathname.slice(0, -1) : pathname + } catch (error) { + return '' + } +} + +export function unifyPath(nodeContext: NodeContext): (path: string) => string { + const serverPrefix = extractOperationBasePath((nodeContext.root as OpenAPIV3.Document).servers) // /api/v2 + return (path) => ( + serverPrefix + ? `${serverPrefix}${hidePathParamNames(path)}` + : hidePathParamNames(path) + ) +} + export function hidePathParamNames(path: string): string { return path.replace(PATH_PARAMETER_REGEXP, PATH_PARAM_UNIFIED_PLACEHOLDER) } From 93cf66d4afeb80bce71ed6e680b3f6ffc6ccfc7f Mon Sep 17 00:00:00 2001 From: CountRedClaw Date: Mon, 15 Sep 2025 15:48:59 +0500 Subject: [PATCH 02/14] fix: add a separate mapper for method --- src/openapi/openapi3.classify.ts | 6 ++--- src/openapi/openapi3.mapping.ts | 45 ++++++++++++++++++-------------- src/openapi/openapi3.rules.ts | 3 ++- src/utils.ts | 10 +++++++ 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/openapi/openapi3.classify.ts b/src/openapi/openapi3.classify.ts index c2c7dd9..c3f061f 100644 --- a/src/openapi/openapi3.classify.ts +++ b/src/openapi/openapi3.classify.ts @@ -14,7 +14,7 @@ import { getKeyValue, isExist, isNotEmptyArray } from '../utils' import { emptySecurity, includeSecurity } from './openapi3.utils' import type { ClassifyRule, CompareContext } from '../types' import { DiffType } from '../types' -import { unifyPath } from './openapi3.mapping' +import { createPathUnifier } from './openapi3.mapping' export const paramClassifyRule: ClassifyRule = [ ({ after }) => { @@ -143,8 +143,8 @@ export const pathChangeClassifyRule: ClassifyRule = [ ({ before, after }) => { const beforePath = before.key as string const afterPath = after.key as string - const unifiedBeforePath = unifyPath(before)(beforePath) - const unifiedAfterPath = unifyPath(after)(afterPath) + const unifiedBeforePath = createPathUnifier(before)(beforePath) + const unifiedAfterPath = createPathUnifier(after)(afterPath) // If unified paths are the same, it means only parameter names changed return unifiedBeforePath === unifiedAfterPath ? annotation : breaking diff --git a/src/openapi/openapi3.mapping.ts b/src/openapi/openapi3.mapping.ts index d760c3a..98b0497 100644 --- a/src/openapi/openapi3.mapping.ts +++ b/src/openapi/openapi3.mapping.ts @@ -1,5 +1,5 @@ import { MapKeysResult, MappingResolver, NodeContext } from '../types' -import { getStringValue, objectKeys, onlyExistedArrayIndexes } from '../utils' +import { difference, getStringValue, intersection, objectKeys, onlyExistedArrayIndexes } from '../utils' import { mapPathParams } from './openapi3.utils' import { OpenAPIV3 } from 'openapi-types' @@ -28,28 +28,33 @@ export const pathMappingResolver: MappingResolver = (before, after, ctx) const result: MapKeysResult = { added: [], removed: [], mapped: {} } - const originalBeforeKeys = objectKeys(before) - const originalAfterKeys = objectKeys(after) - const unifiedAfterKeys = originalAfterKeys.map(unifyPath(ctx.after)) + const unifiedBeforeKeyToKey = Object.fromEntries(objectKeys(before).map(key => [createPathUnifier(ctx.before)(key), key])) + const unifiedAfterKeyToKey = Object.fromEntries(objectKeys(after).map(key => [createPathUnifier(ctx.after)(key), key])) - const notMappedAfterIndices = new Set(originalAfterKeys.keys()) + const unifiedBeforeKeys = Object.keys(unifiedBeforeKeyToKey) + const unifiedAfterKeys = Object.keys(unifiedAfterKeyToKey) - originalBeforeKeys.forEach(beforeKey => { - const unifiedBeforePath = unifyPath(ctx.before)(beforeKey) - const index = unifiedAfterKeys.indexOf(unifiedBeforePath) + result.added = difference(unifiedAfterKeys, unifiedBeforeKeys).map(key => unifiedAfterKeyToKey[key]) + result.removed = difference(unifiedBeforeKeys, unifiedAfterKeys).map(key => unifiedBeforeKeyToKey[key]) + result.mapped = Object.fromEntries( + intersection(unifiedBeforeKeys, unifiedAfterKeys).map(key => [unifiedBeforeKeyToKey[key], unifiedAfterKeyToKey[key]]), + ) - if (index < 0) { - // removed item - result.removed.push(beforeKey) - } else { - // mapped items - result.mapped[beforeKey] = originalAfterKeys[index] - notMappedAfterIndices.delete(index) - } - }) + return result +} + +export const methodMappingResolver: MappingResolver = (before, after) => { + + const result: MapKeysResult = { added: [], removed: [], mapped: {} } + + const beforeKeys = objectKeys(before) + const afterKeys = objectKeys(after) + + result.added = difference(afterKeys, beforeKeys) + result.removed = difference(beforeKeys, afterKeys) - // added items - notMappedAfterIndices.forEach((notMappedIndex) => result.added.push(originalAfterKeys[notMappedIndex])) + const mapped = intersection(beforeKeys, afterKeys) + mapped.forEach(key => result.mapped[key] = key) return result } @@ -196,7 +201,7 @@ export const extractOperationBasePath = (servers?: OpenAPIV3.ServerObject[]): st } } -export function unifyPath(nodeContext: NodeContext): (path: string) => string { +export function createPathUnifier(nodeContext: NodeContext): (path: string) => string { const serverPrefix = extractOperationBasePath((nodeContext.root as OpenAPIV3.Document).servers) // /api/v2 return (path) => ( serverPrefix diff --git a/src/openapi/openapi3.rules.ts b/src/openapi/openapi3.rules.ts index 51652f5..6ca79bb 100644 --- a/src/openapi/openapi3.rules.ts +++ b/src/openapi/openapi3.rules.ts @@ -54,6 +54,7 @@ import { } from './openapi3.classify' import { contentMediaTypeMappingResolver, + methodMappingResolver, paramMappingResolver, pathMappingResolver, singleOperationPathMappingResolver, @@ -390,7 +391,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { const pathItemObjectRules = (options: OpenApi3RulesOptions): CompareRules => ({ $: pathChangeClassifyRule, - mapping: options.mode === COMPARE_MODE_OPERATION ? singleOperationPathMappingResolver : pathMappingResolver, + mapping: options.mode === COMPARE_MODE_OPERATION ? singleOperationPathMappingResolver : methodMappingResolver, '/description': { $: allAnnotation }, '/parameters': { $: [nonBreaking, breaking, breaking], diff --git a/src/utils.ts b/src/utils.ts index 3e645d1..1bea709 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -221,3 +221,13 @@ export const checkPrimitiveType = (value: unknown): PrimitiveType | undefined => } return undefined } + +export function intersection(array1: string[], array2: string[]): string[] { + const set2 = new Set(array2) + return [...new Set(array1.filter(x => set2.has(x)))] +} + +export function difference(array1: string[], array2: string[]): string[] { + const set2 = new Set(array2) + return [...new Set(array1.filter(x => !set2.has(x)))] +} From 300026113e72f1f9a334caec59d5eb7f03e6c1de Mon Sep 17 00:00:00 2001 From: CountRedClaw Date: Mon, 15 Sep 2025 15:58:38 +0500 Subject: [PATCH 03/14] refactor: optimize createPathUnifier calls --- src/openapi/openapi3.mapping.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/openapi/openapi3.mapping.ts b/src/openapi/openapi3.mapping.ts index 98b0497..b126289 100644 --- a/src/openapi/openapi3.mapping.ts +++ b/src/openapi/openapi3.mapping.ts @@ -28,8 +28,11 @@ export const pathMappingResolver: MappingResolver = (before, after, ctx) const result: MapKeysResult = { added: [], removed: [], mapped: {} } - const unifiedBeforeKeyToKey = Object.fromEntries(objectKeys(before).map(key => [createPathUnifier(ctx.before)(key), key])) - const unifiedAfterKeyToKey = Object.fromEntries(objectKeys(after).map(key => [createPathUnifier(ctx.after)(key), key])) + const unifyBeforePath = createPathUnifier(ctx.before) + const unifyAfterPath = createPathUnifier(ctx.after) + + const unifiedBeforeKeyToKey = Object.fromEntries(objectKeys(before).map(key => [unifyBeforePath(key), key])) + const unifiedAfterKeyToKey = Object.fromEntries(objectKeys(after).map(key => [unifyAfterPath(key), key])) const unifiedBeforeKeys = Object.keys(unifiedBeforeKeyToKey) const unifiedAfterKeys = Object.keys(unifiedAfterKeyToKey) From 3de6a50c1e501cf4fe371caff4b47a16ecf68f1c Mon Sep 17 00:00:00 2001 From: CountRedClaw Date: Mon, 15 Sep 2025 19:05:43 +0500 Subject: [PATCH 04/14] test: path change due to moving a prefix from server.url to path classified as annotation --- test/openapi.pathAndMethodMapping.test.ts | 60 +++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 test/openapi.pathAndMethodMapping.test.ts diff --git a/test/openapi.pathAndMethodMapping.test.ts b/test/openapi.pathAndMethodMapping.test.ts new file mode 100644 index 0000000..dcd7ccf --- /dev/null +++ b/test/openapi.pathAndMethodMapping.test.ts @@ -0,0 +1,60 @@ +import { annotation, apiDiff, DiffAction } from '../src' +import { OpenapiBuilder } from './helper' +import { diffsMatcher } from './helper/matchers' + +describe('Path and method mapping', () => { + let openapiBuilder: OpenapiBuilder + + beforeEach(() => { + openapiBuilder = new OpenapiBuilder() + }) + + it('Move prefix from server to path', () => { + const before = openapiBuilder + .addServer('https://example1.com/api/v2') + .addPath({ + path: '/path1', + responses: { + '200': { + description: 'OK', + }, + }, + }) + .getSpec() + + openapiBuilder.reset() + + const after = openapiBuilder + .addServer('https://example1.com') + .addPath({ + path: '/api/v2/path1', responses: { + '200': { + description: 'not OK', + }, + }, + }) + .getSpec() + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/path1']], + afterDeclarationPaths: [['paths', '/api/v2/path1']], + action: DiffAction.rename, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/path1', 'get', 'responses', '200', 'description']], + afterDeclarationPaths: [['paths', '/api/v2/path1', 'get', 'responses', '200', 'description']], + action: DiffAction.replace, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['servers', 0, 'url']], + action: DiffAction.replace, + type: annotation, + }), + ])) + }) +}) From 0cbf0e1f3f4e7b91fa83a42e12ac8491741b551a Mon Sep 17 00:00:00 2001 From: CountRedClaw Date: Wed, 24 Sep 2025 19:16:40 +0500 Subject: [PATCH 05/14] fix: path mapping for a real case with mistyped slashes --- src/openapi/openapi3.mapping.ts | 15 +++++++----- src/utils.ts | 4 ++++ test/openapi.pathAndMethodMapping.test.ts | 29 +++++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/openapi/openapi3.mapping.ts b/src/openapi/openapi3.mapping.ts index b126289..4d31811 100644 --- a/src/openapi/openapi3.mapping.ts +++ b/src/openapi/openapi3.mapping.ts @@ -1,5 +1,12 @@ import { MapKeysResult, MappingResolver, NodeContext } from '../types' -import { difference, getStringValue, intersection, objectKeys, onlyExistedArrayIndexes } from '../utils' +import { + difference, + getStringValue, + intersection, + objectKeys, + onlyExistedArrayIndexes, + removeSlashes, +} from '../utils' import { mapPathParams } from './openapi3.utils' import { OpenAPIV3 } from 'openapi-types' @@ -206,11 +213,7 @@ export const extractOperationBasePath = (servers?: OpenAPIV3.ServerObject[]): st export function createPathUnifier(nodeContext: NodeContext): (path: string) => string { const serverPrefix = extractOperationBasePath((nodeContext.root as OpenAPIV3.Document).servers) // /api/v2 - return (path) => ( - serverPrefix - ? `${serverPrefix}${hidePathParamNames(path)}` - : hidePathParamNames(path) - ) + return (path) => removeSlashes(`${serverPrefix}${hidePathParamNames(path)}`) } export function hidePathParamNames(path: string): string { diff --git a/src/utils.ts b/src/utils.ts index 1bea709..2a4da01 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -231,3 +231,7 @@ export function difference(array1: string[], array2: string[]): string[] { const set2 = new Set(array2) return [...new Set(array1.filter(x => !set2.has(x)))] } + +export function removeSlashes(input: string): string { + return input.replace(/\//g, '') +} diff --git a/test/openapi.pathAndMethodMapping.test.ts b/test/openapi.pathAndMethodMapping.test.ts index dcd7ccf..3f240ad 100644 --- a/test/openapi.pathAndMethodMapping.test.ts +++ b/test/openapi.pathAndMethodMapping.test.ts @@ -57,4 +57,33 @@ describe('Path and method mapping', () => { }), ])) }) + + it('Remove mistyped slashes', () => { + const before = openapiBuilder + .addPath({ + path: '//path1/', + responses: { '200': { description: 'OK' } }, + }) + .getSpec() + + openapiBuilder.reset() + + const after = openapiBuilder + .addPath({ + path: '/path1', + responses: { '200': { description: 'OK' } }, + }) + .getSpec() + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '//path1/']], + afterDeclarationPaths: [['paths', '/path1']], + action: DiffAction.rename, + type: breaking, // todo should be annotation + }) + ])) + }) }) From 3aa5c816a0b88edc8a27149c943b2dde432b3f43 Mon Sep 17 00:00:00 2001 From: b41ex Date: Mon, 29 Sep 2025 15:27:42 +0300 Subject: [PATCH 06/14] fix: expected result for test according to TODO --- test/openapi.pathAndMethodMapping.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/openapi.pathAndMethodMapping.test.ts b/test/openapi.pathAndMethodMapping.test.ts index 3f240ad..648f31e 100644 --- a/test/openapi.pathAndMethodMapping.test.ts +++ b/test/openapi.pathAndMethodMapping.test.ts @@ -82,7 +82,7 @@ describe('Path and method mapping', () => { beforeDeclarationPaths: [['paths', '//path1/']], afterDeclarationPaths: [['paths', '/path1']], action: DiffAction.rename, - type: breaking, // todo should be annotation + type: annotation, }) ])) }) From e52f68b00781eb29e8dd9546668fad32d0ccdc03 Mon Sep 17 00:00:00 2001 From: b41ex Date: Mon, 29 Sep 2025 15:28:09 +0300 Subject: [PATCH 07/14] chore: set feature branch version for dependencies --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f7bf82e..50e84a0 100644 --- a/package.json +++ b/package.json @@ -27,13 +27,13 @@ "update-lock-file": "update-lock-file @netcracker" }, "dependencies": { - "@netcracker/qubership-apihub-api-unifier": "2.4.0", + "@netcracker/qubership-apihub-api-unifier": "feature-performance-optimization", "@netcracker/qubership-apihub-json-crawl": "1.0.4", "fast-equals": "4.0.3" }, "devDependencies": { "@netcracker/qubership-apihub-compatibility-suites": "2.3.0", - "@netcracker/qubership-apihub-graphapi": "1.0.8", + "@netcracker/qubership-apihub-graphapi": "feature-performance-optimization", "@netcracker/qubership-apihub-npm-gitflow": "3.1.0", "@types/jest": "29.5.11", "@types/node": "20.11.6", From 4b01d215fb56af48e03001c0e8c1a5c491c95add Mon Sep 17 00:00:00 2001 From: b41ex Date: Thu, 2 Oct 2025 16:45:43 +0300 Subject: [PATCH 08/14] feat: utility recursive method to aggregate diffs with rollup in merged tree --- src/core/constants.ts | 1 + src/utils.ts | 71 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/core/constants.ts b/src/core/constants.ts index 398e827..6a47f9b 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -1,6 +1,7 @@ import { ClassifyRule } from '../types' export const DIFF_META_KEY = Symbol('$diff') +export const DIFFS_AGGREGATED_META_KEY = Symbol('$diffs-aggregated') export const DEFAULT_NORMALIZED_RESULT = false export const DEFAULT_OPTION_DEFAULTS_META_KEY = Symbol('$defaults') export const DEFAULT_OPTION_ORIGINS_META_KEY = Symbol('$origins') diff --git a/src/utils.ts b/src/utils.ts index 2a4da01..c0e524e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -235,3 +235,74 @@ export function difference(array1: string[], array2: string[]): string[] { export function removeSlashes(input: string): string { return input.replace(/\//g, '') } + +/** + * Traverses the merged document starting from given obj to the bottom and aggregates the diffs with rollup from the bottom up. + * Each object in the tree will have aggregatedDiffProperty only if there are diffs in the object or in the children, + * otherwise the aggregatedDiffProperty is not added. + * Note, that adding/removing the object itself is not included in the aggregation for this object, + * you need retrieve this diffs from parent object if you need them. + * Supports cycled JSO, nested objects and arrays. + * @param obj - The object to aggregate the diffs of. + * @param diffProperty - The property of the object to aggregate the diffs of. + * @param aggregatedDiffProperty - The property of the object to store the aggregated diffs in. + * @returns The aggregated diffs of the given object. + */ + +// TODO: generalize to other use cases (like collecting deprecated) +export function aggregateDiffsWithRollup(obj: any, diffProperty: any, aggregatedDiffProperty: any): Set | undefined { + + const visited = new Set() + + function _aggregateDiffsWithRollup(obj: any): Set | undefined { + if (obj === null || typeof obj !== 'object' ) { + return undefined + } + + if (visited.has(obj)) { + return obj[aggregatedDiffProperty] + } + + visited.add(obj) + + // Process all children and collect their diffs + const childrenDiffs = new Array>() + if (Array.isArray(obj)) { + for (const item of obj) { + const childDiffs = _aggregateDiffsWithRollup(item) + childDiffs && childDiffs.size > 0 && childrenDiffs.push(childDiffs) + } + } else { + for (const [_, value] of Object.entries(obj)) { + const childDiffs = _aggregateDiffsWithRollup(value) + childDiffs && childDiffs.size > 0 && childrenDiffs.push(childDiffs) + } + } + + const hasOwnDiffs = diffProperty in obj + + if (hasOwnDiffs || childrenDiffs.length > 1) { + // obj aggregated diffs are different from children diffs + const aggregatedDiffs = new Set() + for (const childDiffs of childrenDiffs) { + childDiffs.forEach(diff => aggregatedDiffs.add(diff)) + } + const diffs = obj[diffProperty] + for (const key in diffs) { + aggregatedDiffs.add(diffs[key]) + } + // Store the aggregated diffs in the object + obj[aggregatedDiffProperty] = aggregatedDiffs + }else if (childrenDiffs.length === 1) { + // could reuse a child diffs if there is only one + [obj[aggregatedDiffProperty]] = childrenDiffs + }else{ + // no diffs- no aggregated diffs get assigned + } + + return obj[aggregatedDiffProperty] + } + + return _aggregateDiffsWithRollup(obj) +} + From 9a4ce0673aeb47fcae984666a1bd471f20395435 Mon Sep 17 00:00:00 2001 From: b41ex Date: Thu, 2 Oct 2025 17:03:33 +0300 Subject: [PATCH 09/14] fiix: export new API --- src/index.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 2004574..88e61e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,16 @@ export { COMPARE_MODE_DEFAULT, COMPARE_MODE_OPERATION } from './types' export { - ClassifierType, DiffAction, DIFF_META_KEY, breaking, nonBreaking, unclassified, annotation, deprecated, risky, + ClassifierType, + DiffAction, + DIFFS_AGGREGATED_META_KEY, + DIFF_META_KEY, + breaking, + nonBreaking, + unclassified, + annotation, + deprecated, + risky, } from './core' export { apiDiff } from './api' @@ -24,4 +33,7 @@ export { isDiffRename, isDiffReplace, } from './utils' + export { onlyExistedArrayIndexes } from './utils' + +export { aggregateDiffsWithRollup } from './utils' From 76a3f5bdc0e147ba2f4e15250cda1ed979d24b66 Mon Sep 17 00:00:00 2001 From: b41ex Date: Wed, 8 Oct 2025 18:36:49 +0300 Subject: [PATCH 10/14] feat: support path item matching when base path is moved between path and servers array in root object or path item object --- src/openapi/openapi3.classify.ts | 14 ++- src/openapi/openapi3.mapping.ts | 26 ++++-- src/utils.ts | 13 ++- test/helper/index.ts | 1 + .../after.yaml | 14 +++ .../before.yaml | 12 +++ .../after.yaml | 16 ++++ .../before.yaml | 18 ++++ .../path-prefix-path-item-priority/after.yaml | 14 +++ .../before.yaml | 18 ++++ .../after.yaml | 16 ++++ .../before.yaml | 16 ++++ .../after.yaml | 14 +++ .../before.yaml | 14 +++ test/helper/utils.ts | 6 ++ test/openapi.pathAndMethodMapping.test.ts | 92 ++++++++++++++++++- 16 files changed, 284 insertions(+), 20 deletions(-) create mode 100644 test/helper/resources/mixed-case-with-method-prefix-override/after.yaml create mode 100644 test/helper/resources/mixed-case-with-method-prefix-override/before.yaml create mode 100644 test/helper/resources/path-prefix-operation-server-to-path/after.yaml create mode 100644 test/helper/resources/path-prefix-operation-server-to-path/before.yaml create mode 100644 test/helper/resources/path-prefix-path-item-priority/after.yaml create mode 100644 test/helper/resources/path-prefix-path-item-priority/before.yaml create mode 100644 test/helper/resources/path-prefix-path-item-server-to-path/after.yaml create mode 100644 test/helper/resources/path-prefix-path-item-server-to-path/before.yaml create mode 100644 test/helper/resources/path-prefix-root-server-to-path/after.yaml create mode 100644 test/helper/resources/path-prefix-root-server-to-path/before.yaml diff --git a/src/openapi/openapi3.classify.ts b/src/openapi/openapi3.classify.ts index c3f061f..ac12a6e 100644 --- a/src/openapi/openapi3.classify.ts +++ b/src/openapi/openapi3.classify.ts @@ -15,6 +15,7 @@ import { emptySecurity, includeSecurity } from './openapi3.utils' import type { ClassifyRule, CompareContext } from '../types' import { DiffType } from '../types' import { createPathUnifier } from './openapi3.mapping' +import { OpenAPIV3 } from 'openapi-types' export const paramClassifyRule: ClassifyRule = [ ({ after }) => { @@ -140,13 +141,18 @@ export const operationSecurityItemClassifyRule: ClassifyRule = [ export const pathChangeClassifyRule: ClassifyRule = [ nonBreaking, breaking, - ({ before, after }) => { + ({ before, after, parentContext }) => { const beforePath = before.key as string const afterPath = after.key as string - const unifiedBeforePath = createPathUnifier(before)(beforePath) - const unifiedAfterPath = createPathUnifier(after)(afterPath) + const beforeRootServers = (parentContext?.before.root as OpenAPIV3.Document)?.servers + const beforePathItemServers = (before.value as OpenAPIV3.PathItemObject)?.servers + + const afterRootServers = (parentContext?.after.root as OpenAPIV3.Document)?.servers + const afterPathItemServers = (after.value as OpenAPIV3.PathItemObject)?.servers + const unifiedBeforePath = createPathUnifier(beforeRootServers)(beforePath, beforePathItemServers) + const unifiedAfterPath = createPathUnifier(afterRootServers)(afterPath, afterPathItemServers) // If unified paths are the same, it means only parameter names changed return unifiedBeforePath === unifiedAfterPath ? annotation : breaking - } + }, ] diff --git a/src/openapi/openapi3.mapping.ts b/src/openapi/openapi3.mapping.ts index 4d31811..b7f9cdf 100644 --- a/src/openapi/openapi3.mapping.ts +++ b/src/openapi/openapi3.mapping.ts @@ -5,7 +5,7 @@ import { intersection, objectKeys, onlyExistedArrayIndexes, - removeSlashes, + removeExcessiveSlashes, } from '../utils' import { mapPathParams } from './openapi3.utils' import { OpenAPIV3 } from 'openapi-types' @@ -35,11 +35,14 @@ export const pathMappingResolver: MappingResolver = (before, after, ctx) const result: MapKeysResult = { added: [], removed: [], mapped: {} } - const unifyBeforePath = createPathUnifier(ctx.before) - const unifyAfterPath = createPathUnifier(ctx.after) + // current approach for mapping does not allow to match operations between versions + // if base path is specified in the servers array of the operation object, so this case is not supported + // see test "Should match operation when prefix moved from operation object servers to path" + const unifyBeforePath = createPathUnifier((ctx.before.root as OpenAPIV3.Document).servers) + const unifyAfterPath = createPathUnifier((ctx.after.root as OpenAPIV3.Document).servers) - const unifiedBeforeKeyToKey = Object.fromEntries(objectKeys(before).map(key => [unifyBeforePath(key), key])) - const unifiedAfterKeyToKey = Object.fromEntries(objectKeys(after).map(key => [unifyAfterPath(key), key])) + const unifiedBeforeKeyToKey = Object.fromEntries(objectKeys(before).map(key => [unifyBeforePath(key, (before[key] as OpenAPIV3.PathItemObject)?.servers), key])) + const unifiedAfterKeyToKey = Object.fromEntries(objectKeys(after).map(key => [unifyAfterPath(key, (after[key] as OpenAPIV3.PathItemObject)?.servers), key])) const unifiedBeforeKeys = Object.keys(unifiedBeforeKeyToKey) const unifiedAfterKeys = Object.keys(unifiedAfterKeyToKey) @@ -116,10 +119,10 @@ export const contentMediaTypeMappingResolver: MappingResolver = (before, function mapExactMatches( getComparisonKey: (key: string) => string ): void { - + for (const beforeIndex of unmappedBeforeIndices) { const beforeKey = getComparisonKey(beforeKeys[beforeIndex]) - + // Find matching after index by iterating over the after indices set let matchingAfterIndex: number | undefined for (const afterIndex of unmappedAfterIndices) { @@ -211,9 +214,12 @@ export const extractOperationBasePath = (servers?: OpenAPIV3.ServerObject[]): st } } -export function createPathUnifier(nodeContext: NodeContext): (path: string) => string { - const serverPrefix = extractOperationBasePath((nodeContext.root as OpenAPIV3.Document).servers) // /api/v2 - return (path) => removeSlashes(`${serverPrefix}${hidePathParamNames(path)}`) +export function createPathUnifier(rootServers?: OpenAPIV3.ServerObject[]): (path: string, pathServers?: OpenAPIV3.ServerObject[]) => string { + return (path, pathServers) => { + // Prioritize path-level servers over root-level servers + const serverPrefix = extractOperationBasePath(pathServers || rootServers) + return removeExcessiveSlashes(`${serverPrefix}${hidePathParamNames(path)}`) + } } export function hidePathParamNames(path: string): string { diff --git a/src/utils.ts b/src/utils.ts index c0e524e..3ba01ca 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -232,13 +232,16 @@ export function difference(array1: string[], array2: string[]): string[] { return [...new Set(array1.filter(x => !set2.has(x)))] } -export function removeSlashes(input: string): string { - return input.replace(/\//g, '') +export function removeExcessiveSlashes(input: string): string { + return input + .replace(/\/+/g, '/') // Replace multiple consecutive slashes with single slash + .replace(/^\//, '') // Remove leading slash + .replace(/\/$/, '') // Remove trailing slash } /** * Traverses the merged document starting from given obj to the bottom and aggregates the diffs with rollup from the bottom up. - * Each object in the tree will have aggregatedDiffProperty only if there are diffs in the object or in the children, + * Each object in the tree will have aggregatedDiffProperty only if there are diffs in the object or in the children, * otherwise the aggregatedDiffProperty is not added. * Note, that adding/removing the object itself is not included in the aggregation for this object, * you need retrieve this diffs from parent object if you need them. @@ -263,7 +266,7 @@ export function aggregateDiffsWithRollup(obj: any, diffProperty: any, aggregated return obj[aggregatedDiffProperty] } - visited.add(obj) + visited.add(obj) // Process all children and collect their diffs const childrenDiffs = new Array>() @@ -297,7 +300,7 @@ export function aggregateDiffsWithRollup(obj: any, diffProperty: any, aggregated // could reuse a child diffs if there is only one [obj[aggregatedDiffProperty]] = childrenDiffs }else{ - // no diffs- no aggregated diffs get assigned + // no diffs- no aggregated diffs get assigned } return obj[aggregatedDiffProperty] diff --git a/test/helper/index.ts b/test/helper/index.ts index 04a88bc..0db461b 100644 --- a/test/helper/index.ts +++ b/test/helper/index.ts @@ -1,4 +1,5 @@ export * from './openapiBuilder' +export * from './utils' export const TEST_DIFF_FLAG = Symbol('test-diff') export const TEST_INLINE_REF_FLAG = Symbol('test-inline-ref') diff --git a/test/helper/resources/mixed-case-with-method-prefix-override/after.yaml b/test/helper/resources/mixed-case-with-method-prefix-override/after.yaml new file mode 100644 index 0000000..29ac3fa --- /dev/null +++ b/test/helper/resources/mixed-case-with-method-prefix-override/after.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: test + version: 0.1.0 +servers: + - url: https://example1.com/api/v2 +paths: + /changed1: + get: + responses: + '200': + description: a2 + servers: + - url: https://example1.com/api/v1 diff --git a/test/helper/resources/mixed-case-with-method-prefix-override/before.yaml b/test/helper/resources/mixed-case-with-method-prefix-override/before.yaml new file mode 100644 index 0000000..bfc17dc --- /dev/null +++ b/test/helper/resources/mixed-case-with-method-prefix-override/before.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: test + version: 0.1.0 +servers: + - url: https://example1.com/api/v1 +paths: + /changed1: + get: + responses: + '200': + description: a1 diff --git a/test/helper/resources/path-prefix-operation-server-to-path/after.yaml b/test/helper/resources/path-prefix-operation-server-to-path/after.yaml new file mode 100644 index 0000000..b9f7813 --- /dev/null +++ b/test/helper/resources/path-prefix-operation-server-to-path/after.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com +paths: + /api/v1/users: + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-operation-server-to-path/before.yaml b/test/helper/resources/path-prefix-operation-server-to-path/before.yaml new file mode 100644 index 0000000..ea5ca9c --- /dev/null +++ b/test/helper/resources/path-prefix-operation-server-to-path/before.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com +paths: + /users: + get: + summary: Get users + servers: + - url: https://example.com/api/v1 + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-path-item-priority/after.yaml b/test/helper/resources/path-prefix-path-item-priority/after.yaml new file mode 100644 index 0000000..684711d --- /dev/null +++ b/test/helper/resources/path-prefix-path-item-priority/after.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +paths: + /api/v1/users: + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-path-item-priority/before.yaml b/test/helper/resources/path-prefix-path-item-priority/before.yaml new file mode 100644 index 0000000..03b268f --- /dev/null +++ b/test/helper/resources/path-prefix-path-item-priority/before.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com/api/v2 +paths: + /users: + servers: + - url: https://example.com/api/v1 + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-path-item-server-to-path/after.yaml b/test/helper/resources/path-prefix-path-item-server-to-path/after.yaml new file mode 100644 index 0000000..ce380ad --- /dev/null +++ b/test/helper/resources/path-prefix-path-item-server-to-path/after.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +paths: + /api/v1/users: + servers: + - url: https://example.com + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-path-item-server-to-path/before.yaml b/test/helper/resources/path-prefix-path-item-server-to-path/before.yaml new file mode 100644 index 0000000..9767c9d --- /dev/null +++ b/test/helper/resources/path-prefix-path-item-server-to-path/before.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +paths: + /users: + servers: + - url: https://example.com/api/v1 + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-root-server-to-path/after.yaml b/test/helper/resources/path-prefix-root-server-to-path/after.yaml new file mode 100644 index 0000000..e57865f --- /dev/null +++ b/test/helper/resources/path-prefix-root-server-to-path/after.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com +paths: + /api/v1/users: + get: + summary: Get users + responses: + '200': + description: OK + diff --git a/test/helper/resources/path-prefix-root-server-to-path/before.yaml b/test/helper/resources/path-prefix-root-server-to-path/before.yaml new file mode 100644 index 0000000..474e94e --- /dev/null +++ b/test/helper/resources/path-prefix-root-server-to-path/before.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com/api/v1 +paths: + /users: + get: + summary: Get users + responses: + '200': + description: OK + diff --git a/test/helper/utils.ts b/test/helper/utils.ts index 3f673a1..a73aeb8 100644 --- a/test/helper/utils.ts +++ b/test/helper/utils.ts @@ -1,5 +1,11 @@ import { buildSchema } from "graphql" import { buildFromSchema, GraphApiSchema } from '@netcracker/qubership-apihub-graphapi' +import { readFileSync } from 'fs' +import { load } from 'js-yaml' + +export function loadYamlSample(path: string) { + return load(readFileSync(`./test/helper/resources/${path}`).toString()) +} export function takeIf(value: object, condition: boolean): object { return { diff --git a/test/openapi.pathAndMethodMapping.test.ts b/test/openapi.pathAndMethodMapping.test.ts index 648f31e..3f6a4d8 100644 --- a/test/openapi.pathAndMethodMapping.test.ts +++ b/test/openapi.pathAndMethodMapping.test.ts @@ -1,5 +1,5 @@ import { annotation, apiDiff, DiffAction } from '../src' -import { OpenapiBuilder } from './helper' +import { loadYamlSample, OpenapiBuilder } from './helper' import { diffsMatcher } from './helper/matchers' describe('Path and method mapping', () => { @@ -86,4 +86,94 @@ describe('Path and method mapping', () => { }) ])) }) + + it('Should match operation when prefix moved from root servers to path', () => { + const before = loadYamlSample('path-prefix-root-server-to-path/before.yaml') + const after = loadYamlSample('path-prefix-root-server-to-path/after.yaml') + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users']], + afterDeclarationPaths: [['paths', '/api/v1/users']], + action: DiffAction.rename, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['servers', 0, 'url']], + action: DiffAction.replace, + type: annotation, + }), + ])) + }) + + it('Should match operation when prefix moved from path item object servers to path', () => { + const before = loadYamlSample('path-prefix-path-item-server-to-path/before.yaml') + const after = loadYamlSample('path-prefix-path-item-server-to-path/after.yaml') + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users']], + afterDeclarationPaths: [['paths', '/api/v1/users']], + action: DiffAction.rename, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users', 'servers', 0, 'url']], + afterDeclarationPaths: [['paths', '/api/v1/users', 'servers', 0, 'url']], + action: DiffAction.replace, + type: annotation, + }), + ])) + }) + + it('Should prioritize prefix specified in path item object servers to root servers', () => { + const before = loadYamlSample('path-prefix-path-item-priority/before.yaml') + const after = loadYamlSample('path-prefix-path-item-priority/after.yaml') + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + action: "rename", + afterDeclarationPaths: [["paths", "/api/v1/users"]], + beforeDeclarationPaths: [["paths", "/users"]], + type: "annotation", + }), + expect.objectContaining({ + action: "remove", + beforeDeclarationPaths: [["paths", "/users", "servers"]], + type: "annotation", + }), + expect.objectContaining({ + action: "remove", + beforeDeclarationPaths: [["servers"]], + type: "annotation", + }), + ])) + }) + + it.skip('Should match operation when prefix moved from operation object servers to path', () => { + const before = loadYamlSample('path-prefix-operation-server-to-path/before.yaml') + const after = loadYamlSample('path-prefix-operation-server-to-path/after.yaml') + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users']], + afterDeclarationPaths: [['paths', '/api/v1/users']], + action: DiffAction.rename, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users', 'get', 'servers']], + action: DiffAction.remove, + type: annotation, + }), + ])) + }) }) From f252064baf54a58f95acbc2e05a6c23fc33becf6 Mon Sep 17 00:00:00 2001 From: b41ex Date: Sun, 12 Oct 2025 18:24:25 +0300 Subject: [PATCH 11/14] refactor: move extractOperationBasePath and corresponding tests from api-processor to avoid code duplication --- src/index.ts | 7 +++-- src/openapi/openapi3.mapping.ts | 21 +------------ src/utils.ts | 27 +++++++++++++++++ test/utils.test.ts | 54 +++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 22 deletions(-) create mode 100644 test/utils.test.ts diff --git a/src/index.ts b/src/index.ts index 88e61e3..11df071 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,9 @@ export { isDiffReplace, } from './utils' -export { onlyExistedArrayIndexes } from './utils' +export { + aggregateDiffsWithRollup, + extractOperationBasePath, + onlyExistedArrayIndexes +} from './utils' -export { aggregateDiffsWithRollup } from './utils' diff --git a/src/openapi/openapi3.mapping.ts b/src/openapi/openapi3.mapping.ts index b7f9cdf..0bd3e49 100644 --- a/src/openapi/openapi3.mapping.ts +++ b/src/openapi/openapi3.mapping.ts @@ -1,6 +1,7 @@ import { MapKeysResult, MappingResolver, NodeContext } from '../types' import { difference, + extractOperationBasePath, getStringValue, intersection, objectKeys, @@ -194,26 +195,6 @@ function isWildcardCompatible(beforeType: string, afterType: string): boolean { return true } -// todo copy-paste from api-processor -export const extractOperationBasePath = (servers?: OpenAPIV3.ServerObject[]): string => { - if (!Array.isArray(servers) || !servers.length) { return '' } - - try { - const [firstServer] = servers - let serverUrl = firstServer.url - const { variables = {} } = firstServer - - for (const param of Object.keys(variables)) { - serverUrl = serverUrl.replace(new RegExp(`{${param}}`, 'g'), variables[param].default) - } - - const { pathname } = new URL(serverUrl, 'https://localhost') - return pathname.slice(-1) === '/' ? pathname.slice(0, -1) : pathname - } catch (error) { - return '' - } -} - export function createPathUnifier(rootServers?: OpenAPIV3.ServerObject[]): (path: string, pathServers?: OpenAPIV3.ServerObject[]) => string { return (path, pathServers) => { // Prioritize path-level servers over root-level servers diff --git a/src/utils.ts b/src/utils.ts index 3ba01ca..e137939 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -23,6 +23,7 @@ import { JSON_SCHEMA_NODE_TYPE_STRING, JsonSchemaNodesNormalizedType, } from '@netcracker/qubership-apihub-api-unifier' +import { OpenAPIV3 } from 'openapi-types' export const isObject = (value: unknown): value is Record => { return typeof value === 'object' && value !== null @@ -309,3 +310,29 @@ export function aggregateDiffsWithRollup(obj: any, diffProperty: any, aggregated return _aggregateDiffsWithRollup(obj) } +/** + * Extracts the base path (path after the domain) from the first server URL in an array of OpenAPI ServerObjects. + * It replaces any URL variable placeholders (e.g. {host}) with their default values from the 'variables' property. + * The function will return the normalized pathname (without trailing slash) or an empty string on error or if the input is empty. + * + * @param {OpenAPIV3.ServerObject[]} [servers] - An array of OpenAPI ServerObject definitions. + * @returns {string} The base path (pathname) part of the URL, without a trailing slash, or an empty string if unavailable. + */ +export const extractOperationBasePath = (servers?: OpenAPIV3.ServerObject[]): string => { + if (!Array.isArray(servers) || !servers.length) { return '' } + + try { + const [firstServer] = servers + let serverUrl = firstServer.url + const { variables = {} } = firstServer + + for (const param of Object.keys(variables)) { + serverUrl = serverUrl.replace(new RegExp(`{${param}}`, 'g'), variables[param].default) + } + + const { pathname } = new URL(serverUrl, 'https://localhost') + return pathname.slice(-1) === '/' ? pathname.slice(0, -1) : pathname + } catch (error) { + return '' + } +} diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 0000000..d844cff --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2024-2025 NetCracker Technology Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { extractOperationBasePath } from '../src/utils' + +describe('Unit test for extractOperationBasePath', () => { + test('Should handle Servers with parameters correctly', () => { + const servers = [{ + url: '{protocol}://{host}/api', + description: 'Remote server', + variables: { + protocol: { + description: 'Request protocol.', + enum: ['http', 'https'], + default: 'https', + }, + host: { + description: 'Name of the server, for remote development.', + enum: ['billing-ui-api.com'], + default: 'billing-ui-api.com', + }, + }, + }] + + expect(extractOperationBasePath(servers)).toEqual('/api') + }) + + test('Should handle Servers with absolute url correctly', () => { + expect(extractOperationBasePath([{ url: 'https://example.com/v1' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: 'https://example.com/v1/' }])).toEqual('/v1') + }) + + test('Should handle Servers with relative url correctly', () => { + expect(extractOperationBasePath([{ url: '/v1' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: 'v1' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: 'v1/' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: '/v1/' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: '/' }])).toEqual('') + }) +}) + From 69f4ca9fe50893fa0342209a8d986ef21640c452 Mon Sep 17 00:00:00 2001 From: b41ex Date: Sun, 12 Oct 2025 18:25:04 +0300 Subject: [PATCH 12/14] refactor: code formatting --- src/utils.ts | 80 ++++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index e137939..ec2ccd5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -259,52 +259,52 @@ export function aggregateDiffsWithRollup(obj: any, diffProperty: any, aggregated const visited = new Set() function _aggregateDiffsWithRollup(obj: any): Set | undefined { - if (obj === null || typeof obj !== 'object' ) { - return undefined - } + if (!isObject(obj)) { + return undefined + } - if (visited.has(obj)) { - return obj[aggregatedDiffProperty] - } + if (visited.has(obj)) { + return obj[aggregatedDiffProperty] as Set | undefined + } + + visited.add(obj) - visited.add(obj) - - // Process all children and collect their diffs - const childrenDiffs = new Array>() - if (Array.isArray(obj)) { - for (const item of obj) { - const childDiffs = _aggregateDiffsWithRollup(item) - childDiffs && childDiffs.size > 0 && childrenDiffs.push(childDiffs) - } - } else { - for (const [_, value] of Object.entries(obj)) { - const childDiffs = _aggregateDiffsWithRollup(value) - childDiffs && childDiffs.size > 0 && childrenDiffs.push(childDiffs) - } + // Process all children and collect their diffs + const childrenDiffs = new Array>() + if (Array.isArray(obj)) { + for (const item of obj) { + const childDiffs = _aggregateDiffsWithRollup(item) + childDiffs && childDiffs.size > 0 && childrenDiffs.push(childDiffs) + } + } else { + for (const [_, value] of Object.entries(obj)) { + const childDiffs = _aggregateDiffsWithRollup(value) + childDiffs && childDiffs.size > 0 && childrenDiffs.push(childDiffs) } + } + + const hasOwnDiffs = diffProperty in obj - const hasOwnDiffs = diffProperty in obj - - if (hasOwnDiffs || childrenDiffs.length > 1) { - // obj aggregated diffs are different from children diffs - const aggregatedDiffs = new Set() - for (const childDiffs of childrenDiffs) { - childDiffs.forEach(diff => aggregatedDiffs.add(diff)) - } - const diffs = obj[diffProperty] - for (const key in diffs) { - aggregatedDiffs.add(diffs[key]) - } - // Store the aggregated diffs in the object - obj[aggregatedDiffProperty] = aggregatedDiffs - }else if (childrenDiffs.length === 1) { - // could reuse a child diffs if there is only one - [obj[aggregatedDiffProperty]] = childrenDiffs - }else{ - // no diffs- no aggregated diffs get assigned + if (hasOwnDiffs || childrenDiffs.length > 1) { + // obj aggregated diffs are different from children diffs + const aggregatedDiffs = new Set() + for (const childDiffs of childrenDiffs) { + childDiffs.forEach(diff => aggregatedDiffs.add(diff)) } + const diffs = obj[diffProperty] as Record + for (const key in diffs) { + aggregatedDiffs.add(diffs[key]) + } + // Store the aggregated diffs in the object + obj[aggregatedDiffProperty] = aggregatedDiffs + } else if (childrenDiffs.length === 1) { + // could reuse a child diffs if there is only one + [obj[aggregatedDiffProperty]] = childrenDiffs + } else { + // no diffs- no aggregated diffs get assigned + } - return obj[aggregatedDiffProperty] + return obj[aggregatedDiffProperty] as Set | undefined } return _aggregateDiffsWithRollup(obj) From 901425f7f15877de0e5c73e6655d938d97a432b0 Mon Sep 17 00:00:00 2001 From: b41ex Date: Sun, 12 Oct 2025 18:25:35 +0300 Subject: [PATCH 13/14] docs: add description for pathMappingResolver --- src/openapi/openapi3.mapping.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/openapi/openapi3.mapping.ts b/src/openapi/openapi3.mapping.ts index 0bd3e49..714d749 100644 --- a/src/openapi/openapi3.mapping.ts +++ b/src/openapi/openapi3.mapping.ts @@ -32,6 +32,24 @@ export const singleOperationPathMappingResolver: MappingResolver = (befo return result } +/** + * Maps OpenAPI path keys between two versions of the spec by considering possible base path changes + * defined in the root object `servers` field and path item object `servers` field. + * This mapping normalizes (unifies) paths by removing any basePath prefixes + * so that equivalent endpoints are recognized and correctly mapped even if the base path (URL prefix) + * has changed. It does *not* handle server base paths defined at the operation level. + * It also maps paths even if path parameters have changed. + * + * @param before - The "before" object representing a set of OpenAPI paths (mapping string keys to PathItemObject) + * @param after - The "after" object representing a set of OpenAPI paths (mapping string keys to PathItemObject) + * @param ctx - The NodeContext, used here to access the root OpenAPI Document for both "before" and "after" + * @returns {MapKeysResult} An object containing arrays of `added` and `removed` path keys, and + * a mapping between old and new keys for matched paths. + * + * @remarks + * This method does not support mapping when the base path is defined in the operation-level `servers`. + * See related test: "Should match operation when prefix moved from operation object servers to path". + */ export const pathMappingResolver: MappingResolver = (before, after, ctx) => { const result: MapKeysResult = { added: [], removed: [], mapped: {} } From bc4f37e0643fe3527c486ddecd35890c96a8517a Mon Sep 17 00:00:00 2001 From: b41ex Date: Sun, 12 Oct 2025 21:31:21 +0300 Subject: [PATCH 14/14] fix: undefined string is returned as base path if server object is incorrect (has no url property) --- src/utils.ts | 4 ++++ test/utils.test.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/utils.ts b/src/utils.ts index ec2ccd5..afa53f1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -324,6 +324,10 @@ export const extractOperationBasePath = (servers?: OpenAPIV3.ServerObject[]): st try { const [firstServer] = servers let serverUrl = firstServer.url + if(!serverUrl) { + return '' + } + const { variables = {} } = firstServer for (const param of Object.keys(variables)) { diff --git a/test/utils.test.ts b/test/utils.test.ts index d844cff..35de418 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -50,5 +50,12 @@ describe('Unit test for extractOperationBasePath', () => { expect(extractOperationBasePath([{ url: '/v1/' }])).toEqual('/v1') expect(extractOperationBasePath([{ url: '/' }])).toEqual('') }) + + // todo: should really handle this case in api-unifier, it returns incorrect object in this case, + // since url is required for server object + test('Should handle Servers with empty url correctly', () => { + // @ts-expect-error - Testing edge case with missing url property + expect(extractOperationBasePath([{ }])).toEqual('') + }) })