Skip to content
Open
Show file tree
Hide file tree
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@
"update-lock-file": "update-lock-file @netcracker"
},
"dependencies": {
"@netcracker/qubership-apihub-api-unifier": "dev",
"@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": "dev",
"@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",
Expand Down
1 change: 1 addition & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
19 changes: 17 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -24,4 +33,10 @@ export {
isDiffRename,
isDiffReplace,
} from './utils'
export { onlyExistedArrayIndexes } from './utils'

export {
aggregateDiffsWithRollup,
extractOperationBasePath,
onlyExistedArrayIndexes
} from './utils'

20 changes: 13 additions & 7 deletions src/openapi/openapi3.classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import {
breakingIfAfterTrue,
nonBreaking,
PARENT_JUMP,
strictResolveValueFromContext,
reverseClassifyRule,
strictResolveValueFromContext,
transformClassifyRule,
unclassified,
} from '../core'
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 { createPathUnifier } from './openapi3.mapping'
import { OpenAPIV3 } from 'openapi-types'

export const paramClassifyRule: ClassifyRule = [
({ after }) => {
Expand Down Expand Up @@ -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 = hidePathParamNames(beforePath)
const unifiedAfterPath = hidePathParamNames(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
}
},
]
92 changes: 69 additions & 23 deletions src/openapi/openapi3.mapping.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { MapKeysResult, MappingResolver } from '../types'
import { getStringValue, objectKeys, onlyExistedArrayIndexes } from '../utils'
import { MapKeysResult, MappingResolver, NodeContext } from '../types'
import {
difference,
extractOperationBasePath,
getStringValue,
intersection,
objectKeys,
onlyExistedArrayIndexes,
removeExcessiveSlashes,
} from '../utils'
import { mapPathParams } from './openapi3.utils'
import { OpenAPIV3 } from 'openapi-types'

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

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

export const pathMappingResolver: MappingResolver<string> = (before, after) => {
/**
* 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<string>} 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<string> = (before, after, ctx) => {

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

const originalBeforeKeys = objectKeys(before)
const originalAfterKeys = objectKeys(after)
const unifiedAfterKeys = originalAfterKeys.map(hidePathParamNames)
// 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 notMappedAfterIndices = new Set(originalAfterKeys.keys())
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]))

originalBeforeKeys.forEach(beforeKey => {
const unifiedBeforePath = hidePathParamNames(beforeKey)
const index = unifiedAfterKeys.indexOf(unifiedBeforePath)
const unifiedBeforeKeys = Object.keys(unifiedBeforeKeyToKey)
const unifiedAfterKeys = Object.keys(unifiedAfterKeyToKey)

if (index < 0) {
// removed item
result.removed.push(beforeKey)
} else {
// mapped items
result.mapped[beforeKey] = originalAfterKeys[index]
notMappedAfterIndices.delete(index)
}
})
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]]),
)

return result
}

// added items
notMappedAfterIndices.forEach((notMappedIndex) => result.added.push(originalAfterKeys[notMappedIndex]))
export const methodMappingResolver: MappingResolver<string> = (before, after) => {

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

const beforeKeys = objectKeys(before)
const afterKeys = objectKeys(after)

result.added = difference(afterKeys, beforeKeys)
result.removed = difference(beforeKeys, afterKeys)

const mapped = intersection(beforeKeys, afterKeys)
mapped.forEach(key => result.mapped[key] = key)

return result
}
Expand Down Expand Up @@ -100,10 +138,10 @@ export const contentMediaTypeMappingResolver: MappingResolver<string> = (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) {
Expand Down Expand Up @@ -175,6 +213,14 @@ function isWildcardCompatible(beforeType: string, afterType: string): boolean {
return true
}

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 {
return path.replace(PATH_PARAMETER_REGEXP, PATH_PARAM_UNIFIED_PLACEHOLDER)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no comments but because all these manipulations like magic. So I would like to request some documentation for these operations because there a lot of utility functions calling each other and it's difficult to understand whole the algorithm

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added jsdoc for path mapper.

Expand Down
3 changes: 2 additions & 1 deletion src/openapi/openapi3.rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
} from './openapi3.classify'
import {
contentMediaTypeMappingResolver,
methodMappingResolver,
paramMappingResolver,
pathMappingResolver,
singleOperationPathMappingResolver,
Expand Down Expand Up @@ -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],
Expand Down
119 changes: 119 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | symbol, unknown> => {
return typeof value === 'object' && value !== null
Expand Down Expand Up @@ -221,3 +222,121 @@ 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)))]
}

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,
* 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<Diff> | undefined {

const visited = new Set<any>()

function _aggregateDiffsWithRollup(obj: any): Set<Diff> | undefined {
if (!isObject(obj)) {
return undefined
}

if (visited.has(obj)) {
return obj[aggregatedDiffProperty] as Set<Diff> | undefined
}

visited.add(obj)

// Process all children and collect their diffs
const childrenDiffs = new Array<Set<Diff>>()
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<Diff>()
for (const childDiffs of childrenDiffs) {
childDiffs.forEach(diff => aggregatedDiffs.add(diff))
}
const diffs = obj[diffProperty] as Record<string, Diff>
for (const key in diffs) {
aggregatedDiffs.add(diffs[key])
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really need empty else?
and you may format the code

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Else is not empty, it contains comment:) This is to emphasize/remind the reader that in this case to aggregated diffs will be added.

Formatted code.

// 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] as Set<Diff> | undefined
}

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
if(!serverUrl) {
return ''
}

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 ''
}
}
1 change: 1 addition & 0 deletions test/helper/index.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading