Skip to content

Commit 95aba68

Browse files
committed
chore: merge branch 'feature/move-prefix-from-server-to-path' into feature/hash
2 parents 70b8015 + bc4f37e commit 95aba68

File tree

21 files changed

+621
-34
lines changed

21 files changed

+621
-34
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
},
3434
"devDependencies": {
3535
"@netcracker/qubership-apihub-compatibility-suites": "dev",
36-
"@netcracker/qubership-apihub-graphapi": "1.0.8",
36+
"@netcracker/qubership-apihub-graphapi": "feature-performance-optimization",
3737
"@netcracker/qubership-apihub-npm-gitflow": "3.1.0",
3838
"@types/jest": "29.5.11",
3939
"@types/node": "20.11.6",

src/core/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ClassifyRule } from '../types'
22

33
export const DIFF_META_KEY = Symbol('$diff')
4+
export const DIFFS_AGGREGATED_META_KEY = Symbol('$diffs-aggregated')
45
export const DEFAULT_NORMALIZED_RESULT = false
56
export const DEFAULT_OPTION_DEFAULTS_META_KEY = Symbol('$defaults')
67
export const DEFAULT_OPTION_ORIGINS_META_KEY = Symbol('$origins')

src/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
export { COMPARE_MODE_DEFAULT, COMPARE_MODE_OPERATION } from './types'
22

33
export {
4-
ClassifierType, DiffAction, DIFF_META_KEY, breaking, nonBreaking, unclassified, annotation, deprecated, risky,
4+
ClassifierType,
5+
DiffAction,
6+
DIFFS_AGGREGATED_META_KEY,
7+
DIFF_META_KEY,
8+
breaking,
9+
nonBreaking,
10+
unclassified,
11+
annotation,
12+
deprecated,
13+
risky,
514
} from './core'
615

716
export { apiDiff } from './api'
@@ -24,4 +33,10 @@ export {
2433
isDiffRename,
2534
isDiffReplace,
2635
} from './utils'
27-
export { onlyExistedArrayIndexes } from './utils'
36+
37+
export {
38+
aggregateDiffsWithRollup,
39+
extractOperationBasePath,
40+
onlyExistedArrayIndexes
41+
} from './utils'
42+

src/openapi/openapi3.classify.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ import {
55
breakingIfAfterTrue,
66
nonBreaking,
77
PARENT_JUMP,
8-
strictResolveValueFromContext,
98
reverseClassifyRule,
9+
strictResolveValueFromContext,
1010
transformClassifyRule,
1111
unclassified,
1212
} from '../core'
1313
import { getKeyValue, isExist, isNotEmptyArray } from '../utils'
1414
import { emptySecurity, includeSecurity } from './openapi3.utils'
1515
import type { ClassifyRule, CompareContext } from '../types'
1616
import { DiffType } from '../types'
17-
import { hidePathParamNames } from './openapi3.mapping'
17+
import { createPathUnifier } from './openapi3.mapping'
18+
import { OpenAPIV3 } from 'openapi-types'
1819

1920
export const paramClassifyRule: ClassifyRule = [
2021
({ after }) => {
@@ -140,13 +141,18 @@ export const operationSecurityItemClassifyRule: ClassifyRule = [
140141
export const pathChangeClassifyRule: ClassifyRule = [
141142
nonBreaking,
142143
breaking,
143-
({ before, after }) => {
144+
({ before, after, parentContext }) => {
144145
const beforePath = before.key as string
145146
const afterPath = after.key as string
146-
const unifiedBeforePath = hidePathParamNames(beforePath)
147-
const unifiedAfterPath = hidePathParamNames(afterPath)
148-
147+
const beforeRootServers = (parentContext?.before.root as OpenAPIV3.Document)?.servers
148+
const beforePathItemServers = (before.value as OpenAPIV3.PathItemObject)?.servers
149+
150+
const afterRootServers = (parentContext?.after.root as OpenAPIV3.Document)?.servers
151+
const afterPathItemServers = (after.value as OpenAPIV3.PathItemObject)?.servers
152+
153+
const unifiedBeforePath = createPathUnifier(beforeRootServers)(beforePath, beforePathItemServers)
154+
const unifiedAfterPath = createPathUnifier(afterRootServers)(afterPath, afterPathItemServers)
149155
// If unified paths are the same, it means only parameter names changed
150156
return unifiedBeforePath === unifiedAfterPath ? annotation : breaking
151-
}
157+
},
152158
]

src/openapi/openapi3.mapping.ts

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
import type { MapKeysResult, MappingResolver } from '../types'
2-
import { getStringValue, objectKeys, onlyExistedArrayIndexes } from '../utils'
1+
import { MapKeysResult, MappingResolver, NodeContext } from '../types'
2+
import {
3+
difference,
4+
extractOperationBasePath,
5+
getStringValue,
6+
intersection,
7+
objectKeys,
8+
onlyExistedArrayIndexes,
9+
removeExcessiveSlashes,
10+
} from '../utils'
311
import { mapPathParams } from './openapi3.utils'
12+
import { OpenAPIV3 } from 'openapi-types'
413

514
export const singleOperationPathMappingResolver: MappingResolver<string> = (before, after) => {
615

@@ -23,32 +32,61 @@ export const singleOperationPathMappingResolver: MappingResolver<string> = (befo
2332
return result
2433
}
2534

26-
export const pathMappingResolver: MappingResolver<string> = (before, after) => {
35+
/**
36+
* Maps OpenAPI path keys between two versions of the spec by considering possible base path changes
37+
* defined in the root object `servers` field and path item object `servers` field.
38+
* This mapping normalizes (unifies) paths by removing any basePath prefixes
39+
* so that equivalent endpoints are recognized and correctly mapped even if the base path (URL prefix)
40+
* has changed. It does *not* handle server base paths defined at the operation level.
41+
* It also maps paths even if path parameters have changed.
42+
*
43+
* @param before - The "before" object representing a set of OpenAPI paths (mapping string keys to PathItemObject)
44+
* @param after - The "after" object representing a set of OpenAPI paths (mapping string keys to PathItemObject)
45+
* @param ctx - The NodeContext, used here to access the root OpenAPI Document for both "before" and "after"
46+
* @returns {MapKeysResult<string>} An object containing arrays of `added` and `removed` path keys, and
47+
* a mapping between old and new keys for matched paths.
48+
*
49+
* @remarks
50+
* This method does not support mapping when the base path is defined in the operation-level `servers`.
51+
* See related test: "Should match operation when prefix moved from operation object servers to path".
52+
*/
53+
export const pathMappingResolver: MappingResolver<string> = (before, after, ctx) => {
2754

2855
const result: MapKeysResult<string> = { added: [], removed: [], mapped: {} }
2956

30-
const originalBeforeKeys = objectKeys(before)
31-
const originalAfterKeys = objectKeys(after)
32-
const unifiedAfterKeys = originalAfterKeys.map(hidePathParamNames)
57+
// current approach for mapping does not allow to match operations between versions
58+
// if base path is specified in the servers array of the operation object, so this case is not supported
59+
// see test "Should match operation when prefix moved from operation object servers to path"
60+
const unifyBeforePath = createPathUnifier((ctx.before.root as OpenAPIV3.Document).servers)
61+
const unifyAfterPath = createPathUnifier((ctx.after.root as OpenAPIV3.Document).servers)
3362

34-
const notMappedAfterIndices = new Set(originalAfterKeys.keys())
63+
const unifiedBeforeKeyToKey = Object.fromEntries(objectKeys(before).map(key => [unifyBeforePath(key, (before[key] as OpenAPIV3.PathItemObject)?.servers), key]))
64+
const unifiedAfterKeyToKey = Object.fromEntries(objectKeys(after).map(key => [unifyAfterPath(key, (after[key] as OpenAPIV3.PathItemObject)?.servers), key]))
3565

36-
originalBeforeKeys.forEach(beforeKey => {
37-
const unifiedBeforePath = hidePathParamNames(beforeKey)
38-
const index = unifiedAfterKeys.indexOf(unifiedBeforePath)
66+
const unifiedBeforeKeys = Object.keys(unifiedBeforeKeyToKey)
67+
const unifiedAfterKeys = Object.keys(unifiedAfterKeyToKey)
3968

40-
if (index < 0) {
41-
// removed item
42-
result.removed.push(beforeKey)
43-
} else {
44-
// mapped items
45-
result.mapped[beforeKey] = originalAfterKeys[index]
46-
notMappedAfterIndices.delete(index)
47-
}
48-
})
69+
result.added = difference(unifiedAfterKeys, unifiedBeforeKeys).map(key => unifiedAfterKeyToKey[key])
70+
result.removed = difference(unifiedBeforeKeys, unifiedAfterKeys).map(key => unifiedBeforeKeyToKey[key])
71+
result.mapped = Object.fromEntries(
72+
intersection(unifiedBeforeKeys, unifiedAfterKeys).map(key => [unifiedBeforeKeyToKey[key], unifiedAfterKeyToKey[key]]),
73+
)
74+
75+
return result
76+
}
4977

50-
// added items
51-
notMappedAfterIndices.forEach((notMappedIndex) => result.added.push(originalAfterKeys[notMappedIndex]))
78+
export const methodMappingResolver: MappingResolver<string> = (before, after) => {
79+
80+
const result: MapKeysResult<string> = { added: [], removed: [], mapped: {} }
81+
82+
const beforeKeys = objectKeys(before)
83+
const afterKeys = objectKeys(after)
84+
85+
result.added = difference(afterKeys, beforeKeys)
86+
result.removed = difference(beforeKeys, afterKeys)
87+
88+
const mapped = intersection(beforeKeys, afterKeys)
89+
mapped.forEach(key => result.mapped[key] = key)
5290

5391
return result
5492
}
@@ -100,10 +138,10 @@ export const contentMediaTypeMappingResolver: MappingResolver<string> = (before,
100138
function mapExactMatches(
101139
getComparisonKey: (key: string) => string
102140
): void {
103-
141+
104142
for (const beforeIndex of unmappedBeforeIndices) {
105143
const beforeKey = getComparisonKey(beforeKeys[beforeIndex])
106-
144+
107145
// Find matching after index by iterating over the after indices set
108146
let matchingAfterIndex: number | undefined
109147
for (const afterIndex of unmappedAfterIndices) {
@@ -175,6 +213,14 @@ function isWildcardCompatible(beforeType: string, afterType: string): boolean {
175213
return true
176214
}
177215

216+
export function createPathUnifier(rootServers?: OpenAPIV3.ServerObject[]): (path: string, pathServers?: OpenAPIV3.ServerObject[]) => string {
217+
return (path, pathServers) => {
218+
// Prioritize path-level servers over root-level servers
219+
const serverPrefix = extractOperationBasePath(pathServers || rootServers)
220+
return removeExcessiveSlashes(`${serverPrefix}${hidePathParamNames(path)}`)
221+
}
222+
}
223+
178224
export function hidePathParamNames(path: string): string {
179225
return path.replace(PATH_PARAMETER_REGEXP, PATH_PARAM_UNIFIED_PLACEHOLDER)
180226
}

src/openapi/openapi3.rules.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
} from './openapi3.classify'
5555
import {
5656
contentMediaTypeMappingResolver,
57+
methodMappingResolver,
5758
paramMappingResolver,
5859
pathMappingResolver,
5960
singleOperationPathMappingResolver,
@@ -390,7 +391,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => {
390391

391392
const pathItemObjectRules = (options: OpenApi3RulesOptions): CompareRules => ({
392393
$: pathChangeClassifyRule,
393-
mapping: options.mode === COMPARE_MODE_OPERATION ? singleOperationPathMappingResolver : pathMappingResolver,
394+
mapping: options.mode === COMPARE_MODE_OPERATION ? singleOperationPathMappingResolver : methodMappingResolver,
394395
'/description': { $: allAnnotation },
395396
'/parameters': {
396397
$: [nonBreaking, breaking, breaking],

src/utils.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
JSON_SCHEMA_NODE_TYPE_STRING,
2424
JsonSchemaNodesNormalizedType,
2525
} from '@netcracker/qubership-apihub-api-unifier'
26+
import { OpenAPIV3 } from 'openapi-types'
2627

2728
export const isObject = (value: unknown): value is Record<string | symbol, unknown> => {
2829
return typeof value === 'object' && value !== null
@@ -221,3 +222,121 @@ export const checkPrimitiveType = (value: unknown): PrimitiveType | undefined =>
221222
}
222223
return undefined
223224
}
225+
226+
export function intersection(array1: string[], array2: string[]): string[] {
227+
const set2 = new Set(array2)
228+
return [...new Set(array1.filter(x => set2.has(x)))]
229+
}
230+
231+
export function difference(array1: string[], array2: string[]): string[] {
232+
const set2 = new Set(array2)
233+
return [...new Set(array1.filter(x => !set2.has(x)))]
234+
}
235+
236+
export function removeExcessiveSlashes(input: string): string {
237+
return input
238+
.replace(/\/+/g, '/') // Replace multiple consecutive slashes with single slash
239+
.replace(/^\//, '') // Remove leading slash
240+
.replace(/\/$/, '') // Remove trailing slash
241+
}
242+
243+
/**
244+
* Traverses the merged document starting from given obj to the bottom and aggregates the diffs with rollup from the bottom up.
245+
* Each object in the tree will have aggregatedDiffProperty only if there are diffs in the object or in the children,
246+
* otherwise the aggregatedDiffProperty is not added.
247+
* Note, that adding/removing the object itself is not included in the aggregation for this object,
248+
* you need retrieve this diffs from parent object if you need them.
249+
* Supports cycled JSO, nested objects and arrays.
250+
* @param obj - The object to aggregate the diffs of.
251+
* @param diffProperty - The property of the object to aggregate the diffs of.
252+
* @param aggregatedDiffProperty - The property of the object to store the aggregated diffs in.
253+
* @returns The aggregated diffs of the given object.
254+
*/
255+
256+
// TODO: generalize to other use cases (like collecting deprecated)
257+
export function aggregateDiffsWithRollup(obj: any, diffProperty: any, aggregatedDiffProperty: any): Set<Diff> | undefined {
258+
259+
const visited = new Set<any>()
260+
261+
function _aggregateDiffsWithRollup(obj: any): Set<Diff> | undefined {
262+
if (!isObject(obj)) {
263+
return undefined
264+
}
265+
266+
if (visited.has(obj)) {
267+
return obj[aggregatedDiffProperty] as Set<Diff> | undefined
268+
}
269+
270+
visited.add(obj)
271+
272+
// Process all children and collect their diffs
273+
const childrenDiffs = new Array<Set<Diff>>()
274+
if (Array.isArray(obj)) {
275+
for (const item of obj) {
276+
const childDiffs = _aggregateDiffsWithRollup(item)
277+
childDiffs && childDiffs.size > 0 && childrenDiffs.push(childDiffs)
278+
}
279+
} else {
280+
for (const [_, value] of Object.entries(obj)) {
281+
const childDiffs = _aggregateDiffsWithRollup(value)
282+
childDiffs && childDiffs.size > 0 && childrenDiffs.push(childDiffs)
283+
}
284+
}
285+
286+
const hasOwnDiffs = diffProperty in obj
287+
288+
if (hasOwnDiffs || childrenDiffs.length > 1) {
289+
// obj aggregated diffs are different from children diffs
290+
const aggregatedDiffs = new Set<Diff>()
291+
for (const childDiffs of childrenDiffs) {
292+
childDiffs.forEach(diff => aggregatedDiffs.add(diff))
293+
}
294+
const diffs = obj[diffProperty] as Record<string, Diff>
295+
for (const key in diffs) {
296+
aggregatedDiffs.add(diffs[key])
297+
}
298+
// Store the aggregated diffs in the object
299+
obj[aggregatedDiffProperty] = aggregatedDiffs
300+
} else if (childrenDiffs.length === 1) {
301+
// could reuse a child diffs if there is only one
302+
[obj[aggregatedDiffProperty]] = childrenDiffs
303+
} else {
304+
// no diffs- no aggregated diffs get assigned
305+
}
306+
307+
return obj[aggregatedDiffProperty] as Set<Diff> | undefined
308+
}
309+
310+
return _aggregateDiffsWithRollup(obj)
311+
}
312+
313+
/**
314+
* Extracts the base path (path after the domain) from the first server URL in an array of OpenAPI ServerObjects.
315+
* It replaces any URL variable placeholders (e.g. {host}) with their default values from the 'variables' property.
316+
* The function will return the normalized pathname (without trailing slash) or an empty string on error or if the input is empty.
317+
*
318+
* @param {OpenAPIV3.ServerObject[]} [servers] - An array of OpenAPI ServerObject definitions.
319+
* @returns {string} The base path (pathname) part of the URL, without a trailing slash, or an empty string if unavailable.
320+
*/
321+
export const extractOperationBasePath = (servers?: OpenAPIV3.ServerObject[]): string => {
322+
if (!Array.isArray(servers) || !servers.length) { return '' }
323+
324+
try {
325+
const [firstServer] = servers
326+
let serverUrl = firstServer.url
327+
if(!serverUrl) {
328+
return ''
329+
}
330+
331+
const { variables = {} } = firstServer
332+
333+
for (const param of Object.keys(variables)) {
334+
serverUrl = serverUrl.replace(new RegExp(`{${param}}`, 'g'), variables[param].default)
335+
}
336+
337+
const { pathname } = new URL(serverUrl, 'https://localhost')
338+
return pathname.slice(-1) === '/' ? pathname.slice(0, -1) : pathname
339+
} catch (error) {
340+
return ''
341+
}
342+
}

test/helper/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './openapiBuilder'
2+
export * from './utils'
23

34
export const TEST_DIFF_FLAG = Symbol('test-diff')
45
export const TEST_INLINE_REF_FLAG = Symbol('test-inline-ref')
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
openapi: 3.0.3
2+
info:
3+
title: test
4+
version: 0.1.0
5+
servers:
6+
- url: https://example1.com/api/v2
7+
paths:
8+
/changed1:
9+
get:
10+
responses:
11+
'200':
12+
description: a2
13+
servers:
14+
- url: https://example1.com/api/v1
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
openapi: 3.0.3
2+
info:
3+
title: test
4+
version: 0.1.0
5+
servers:
6+
- url: https://example1.com/api/v1
7+
paths:
8+
/changed1:
9+
get:
10+
responses:
11+
'200':
12+
description: a1

0 commit comments

Comments
 (0)