Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
feaad19
feat: Build changelog for AsyncAPI specifications
makeev-pavel Mar 13, 2026
67cdc64
feat: More tests
makeev-pavel Mar 13, 2026
00ff16f
feat: Async api kind
makeev-pavel Mar 16, 2026
5bb3a3b
feat: Review
makeev-pavel Mar 16, 2026
76aaf9d
feat: ApiKind tests refactoring
makeev-pavel Mar 16, 2026
8cf4b49
feat: asyncapi-changes tests refactoring
makeev-pavel Mar 16, 2026
cd4024f
feat: added asyncapi-changes tests
makeev-pavel Mar 17, 2026
42885cd
feat: added tests for asyncapi duplication
makeev-pavel Mar 18, 2026
beeae42
feat: fixed ChannelMessage
makeev-pavel Mar 18, 2026
7dc82e6
feat: Added tests + refactoring
makeev-pavel Mar 18, 2026
cfe4eca
feat: Update async server
makeev-pavel Mar 19, 2026
ff2e65b
feat: Update package.json
makeev-pavel Mar 19, 2026
65bba8c
Merge branch 'develop' into feature/asyncapi-basic-e2e-changelog
makeev-pavel Mar 19, 2026
7c94586
chore: set dev version for dependencies
b41ex Mar 19, 2026
7bff280
feat: Update package.json and tests
makeev-pavel Mar 19, 2026
991cf03
Merge remote-tracking branch 'origin/feature/asyncapi-basic-e2e-chang…
makeev-pavel Mar 19, 2026
5e1d2e9
feat: Update tests, moved bwc validation to different files rest, async
makeev-pavel Mar 23, 2026
69a6b5a
feat: Update bwc logic
makeev-pavel Mar 24, 2026
00f8e60
feat: Update bwc tests
makeev-pavel Mar 24, 2026
90966cc
feat: test review
makeev-pavel Mar 24, 2026
255bec1
feat: Tests refactoring
makeev-pavel Mar 25, 2026
e8afd83
feat: Review
makeev-pavel Mar 25, 2026
12006f3
feat: Fix duplicate operation rest severity
makeev-pavel Mar 25, 2026
7a4cafa
feat: added test data
makeev-pavel Mar 25, 2026
67c31b0
feat: Fixed async defaultContentType
makeev-pavel Mar 25, 2026
e6f72f0
feat: Fixed async apikind
makeev-pavel Mar 26, 2026
bc6c7eb
feat: Fixed tests. Added duplicate operationId validation tests
makeev-pavel Mar 26, 2026
757832d
feat: Fixed DefaultContentType and message ContentType
makeev-pavel Mar 27, 2026
4ca9d20
feat: Fixed detect to report in tests
makeev-pavel Mar 27, 2026
6a3218e
feat: Added asyncapi-changelog-apikind tests. Review
makeev-pavel Mar 30, 2026
953959f
feat: Added asyncapi-changelog-apikind tests v2
makeev-pavel Mar 30, 2026
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
62 changes: 44 additions & 18 deletions src/apitypes/async/async.changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import {
AFTER_VALUE_NORMALIZED_PROPERTY,
BEFORE_VALUE_NORMALIZED_PROPERTY,
FIRST_REFERENCE_KEY_PROPERTY,
NORMALIZE_OPTIONS,
ORIGINS_SYMBOL,
} from '../../consts'
Expand All @@ -45,8 +46,16 @@ import {
getOperationTags,
OperationsMap,
} from '../../components'
import { createAsyncApiCompatibilityScopeFunction } from '../../components/compare/async.bwc.validation'
import { v3 as AsyncAPIV3 } from '@asyncapi/parser/esm/spec-types'
import { getAsyncMessageId } from './async.utils'
import {
collectChannelMessageDefinitionDiffs,
collectExclusiveOtherMessageDiffs,
extractAsyncApiVersionDiff,
extractIdDiff,
extractInfoDiffs,
getAsyncMessageId,
} from './async.utils'

export const compareDocuments: DocumentsCompare = async (
operationsMap: OperationsMap,
Expand Down Expand Up @@ -86,9 +95,11 @@ export const compareDocuments: DocumentsCompare = async (
...NORMALIZE_OPTIONS,
metaKey: DIFF_META_KEY,
originsFlag: ORIGINS_SYMBOL,
normalizedResult: true,
normalizedResult: false,
afterValueNormalizedProperty: AFTER_VALUE_NORMALIZED_PROPERTY,
beforeValueNormalizedProperty: BEFORE_VALUE_NORMALIZED_PROPERTY,
firstReferenceKeyProperty: FIRST_REFERENCE_KEY_PROPERTY,
apiCompatibilityScopeFunction: createAsyncApiCompatibilityScopeFunction(),
},
) as { merged: AsyncAPIV3.AsyncAPIObject; diffs: Diff[] }

Expand All @@ -101,10 +112,18 @@ export const compareDocuments: DocumentsCompare = async (
const tags = new Set<string>()
const operationChanges: OperationChanges[] = []

// Precompute root-level diffs once (shared across all operations)
const asyncApiVersionDiffs = extractAsyncApiVersionDiff(merged)
const infoDiffs = extractInfoDiffs(merged)
const idDiffs = extractIdDiff(merged)
// Note: defaultContentType changes are handled by normalization inside apiDiff.
// Messages without explicit contentType inherit from defaultContentType during normalization,
// so changes to defaultContentType automatically appear in allOperationDiffs for affected messages.

// Iterate through operations in merged document
const { operations } = merged
if (operations && isObject(operations)) {
for (const [asyncOperationId, operationData] of Object.entries(operations)) {
const { operations: asyncOperations } = merged
if (asyncOperations && isObject(asyncOperations)) {
for (const [asyncOperationId, operationData] of Object.entries(asyncOperations)) {
if (!operationData || !isObject(operationData)) {
continue
}
Expand All @@ -114,16 +133,15 @@ export const compareDocuments: DocumentsCompare = async (
if (!Array.isArray(messages) || messages.length === 0) {
continue
}
// Extract action and channel from operation

const { action, channel: operationChannel } = operationObject
if (!action || !operationChannel) {
continue
}
for (const message of messages) {
// Use simple operation ID (no normalization needed for AsyncAPI)

for (const [messageIndex, message] of messages.entries()) {
const messageId = getAsyncMessageId(message)
const operationId = calculateAsyncOperationId(asyncOperationId, messageId)

const {
current,
previous,
Expand All @@ -137,18 +155,26 @@ export const compareDocuments: DocumentsCompare = async (

let operationDiffs: Diff[] = []
if (operationPotentiallyChanged) {
const allOperationDiffs = (operationObject as WithAggregatedDiffs<AsyncAPIV3.OperationObject>)[DIFFS_AGGREGATED_META_KEY] ?? []

const otherMessageDiffs = collectExclusiveOtherMessageDiffs(messages, messageIndex)
const channelMessageDiffs = collectChannelMessageDefinitionDiffs(operationChannel as AsyncAPIV3.ChannelObject)

operationDiffs = [
...(operationObject as WithAggregatedDiffs<AsyncAPIV3.OperationObject>)[DIFFS_AGGREGATED_META_KEY] ?? [],
// TODO: check
// ...extractAsyncApiVersionDiff(merged),
// ...extractRootServersDiffs(merged),
// ...extractChannelsDiffs(merged, operationChannel),
...([...allOperationDiffs].filter(diff => !otherMessageDiffs.has(diff) && !channelMessageDiffs.has(diff))),
...asyncApiVersionDiffs,
...infoDiffs,
...idDiffs,
]
}
if (operationAddedOrRemoved) {
const operationAddedOrRemovedDiff = (operations as WithDiffMetaRecord<AsyncAPIV3.OperationsObject>)[DIFF_META_KEY]?.[asyncOperationId]
if (operationAddedOrRemovedDiff) {
operationDiffs.push(operationAddedOrRemovedDiff)
// Level 1: message added/removed within an existing operation (analogous to REST method within path)
const messageAddedOrRemovedDiff = (messages as WithDiffMetaRecord<AsyncAPIV3.MessageObject[]>)[DIFF_META_KEY]?.[messageIndex]
// Level 2: entire operation added/removed (analogous to REST entire path)
const operationAddedOrRemovedDiff = (asyncOperations as WithDiffMetaRecord<AsyncAPIV3.OperationsObject>)[DIFF_META_KEY]?.[asyncOperationId]
const diff = messageAddedOrRemovedDiff ?? operationAddedOrRemovedDiff
if (diff) {
operationDiffs.push(diff)
}
}

Expand All @@ -172,7 +198,7 @@ export const compareDocuments: DocumentsCompare = async (
return {
operationChanges,
tags,
...(comparisonDocument) ? { comparisonDocument } : {},
...(comparisonDocument ? { comparisonDocument } : {}),
}
}

Expand Down
98 changes: 93 additions & 5 deletions src/apitypes/async/async.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
getSymbolValueIfDefined,
isObject,
isReferenceObject,
setValueByPath, takeIfDefined,
setValueByPath,
takeIfDefined,
} from '../../utils'
import type * as TYPE from './async.types'
import {
Expand All @@ -42,6 +43,8 @@ import {
FIRST_REFERENCE_KEY_PROPERTY,
INLINE_REFS_FLAG,
} from '../../consts'
import { WithAggregatedDiffs, WithDiffMetaRecord } from '../../types'
import { Diff, DiffAction, DIFF_META_KEY, DIFFS_AGGREGATED_META_KEY } from '@netcracker/qubership-apihub-api-diff'

// Re-export shared utilities
export { dump, getCustomTags, resolveApiAudience } from '../../utils/apihubSpecificationExtensions'
Expand Down Expand Up @@ -146,8 +149,8 @@ export const createBaseAsyncApiSpec = (
): TYPE.AsyncOperationData => ({
asyncapi: document.asyncapi || '3.0.0',
info: document.info,
...takeIfDefined({id: document.id}),
...takeIfDefined({defaultContentType: document.defaultContentType}),
...takeIfDefined({ id: document.id }),
...takeIfDefined({ defaultContentType: document.defaultContentType }),
operations,
})

Expand Down Expand Up @@ -236,11 +239,11 @@ export const resolveAsyncApiOperationIdsFromRefs = (
}

for (const message of messages) {
if(!isMessageObject(message)){
if (!isMessageObject(message)) {
continue
}
const inlineRefs = getSymbolValueIfDefined(message, INLINE_REFS_FLAG) as string[] | undefined
if (!inlineRefs || inlineRefs.length === 0){
if (!inlineRefs || inlineRefs.length === 0) {
continue
}
const lastInlineRef = inlineRefs.at(-1)
Expand All @@ -267,3 +270,88 @@ export const resolveAsyncApiOperationIdsFromRefs = (

return resolved
}

/**
* Aggregated diffs on the operation level include diffs from ALL messages.
* Since each apihub operation maps to a specific operation + message pair,
* diffs from sibling messages must be excluded to prevent them from leaking
* into unrelated apihub operations.
*
* Collects two kinds of diffs that belong exclusively to other messages:
* 1. Aggregated content diffs from each sibling message object
* 2. Array-level diffs for adding/removing sibling messages from the messages list
*
* Diffs shared between the current message and sibling messages (e.g. from a shared
* component schema) are NOT included, so they won't be incorrectly filtered out.
*/
export function collectExclusiveOtherMessageDiffs(messages: AsyncAPIV3.MessageObject[], currentMessageIndex: number): Set<Diff> {
const currentMessageDiffsArr = (messages[currentMessageIndex] as WithAggregatedDiffs<AsyncAPIV3.MessageObject>)[DIFFS_AGGREGATED_META_KEY]
const currentMessageDiffs = new Set(currentMessageDiffsArr ?? [])

const otherDiffs = new Set<Diff>()
for (const [messageIndex, message] of messages.entries()) {
if (messageIndex === currentMessageIndex) continue
const messageDiffs = (message as WithAggregatedDiffs<AsyncAPIV3.MessageObject>)[DIFFS_AGGREGATED_META_KEY]
if (messageDiffs) {
for (const messageDiff of messageDiffs) {
if (!currentMessageDiffs.has(messageDiff)) {
otherDiffs.add(messageDiff)
}
}
}
}
const messagesArrayMeta = (messages as WithDiffMetaRecord<AsyncAPIV3.MessageObject[]>)[DIFF_META_KEY]
if (messagesArrayMeta) {
for (const key in messagesArrayMeta) {
if (Number(key) !== currentMessageIndex) {
otherDiffs.add(messagesArrayMeta[key])
}
}
}
return otherDiffs
}

/**
* Collects diffs for adding/removing message definitions in channel.messages.
* These are channel-level definition changes that should not propagate to operations,
* because what matters is whether the operation's own messages array references a message,
* not whether the channel defines it.
*/
export function collectChannelMessageDefinitionDiffs(operationChannel: AsyncAPIV3.ChannelObject): Set<Diff> {
const channelMessages = (operationChannel as Record<string, unknown>).messages
if (!isObject(channelMessages)) {
return new Set()
}
const diffs = new Set<Diff>()
const messagesMeta = (channelMessages as WithDiffMetaRecord<object>)[DIFF_META_KEY]
if (messagesMeta) {
for (const key in messagesMeta) {
diffs.add(messagesMeta[key])
}
}
return diffs
}

export function extractAsyncApiVersionDiff(doc: AsyncAPIV3.AsyncAPIObject): Diff[] {
const diff = (doc as WithDiffMetaRecord<AsyncAPIV3.AsyncAPIObject>)[DIFF_META_KEY]?.asyncapi
return diff ? [diff] : []
}

export function extractInfoDiffs(doc: AsyncAPIV3.AsyncAPIObject): Diff[] {
const addOrRemoveInfoDiff = (doc as WithDiffMetaRecord<AsyncAPIV3.AsyncAPIObject>)[DIFF_META_KEY]?.info
const infoInternalDiffs = (doc.info as WithAggregatedDiffs<AsyncAPIV3.InfoObject>)?.[DIFFS_AGGREGATED_META_KEY] ?? []
return [
...(addOrRemoveInfoDiff ? [addOrRemoveInfoDiff] : []),
...infoInternalDiffs,
]
}

export function extractIdDiff(doc: AsyncAPIV3.AsyncAPIObject): Diff[] {
const diff = (doc as WithDiffMetaRecord<AsyncAPIV3.AsyncAPIObject>)[DIFF_META_KEY]?.id
return diff ? [diff] : []
}

export function extractDefaultContentTypeDiff(doc: AsyncAPIV3.AsyncAPIObject): Diff[] {
const diff = (doc as WithDiffMetaRecord<AsyncAPIV3.AsyncAPIObject>)[DIFF_META_KEY]?.defaultContentType
return diff ? [diff] : []
}
4 changes: 2 additions & 2 deletions src/apitypes/rest/rest.changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ import {
getOperationTags,
OperationsMap,
} from '../../components'
import { createApihubApiCompatibilityScopeFunction } from '../../components/compare/bwc.validation'
import { createRestApiCompatibilityScopeFunction } from '../../components/compare/rest.bwc.validation'
import { calculateApiKindFromLabels, getApiKindProperty } from '../../components/document'

export const compareDocuments: DocumentsCompare = async (
Expand Down Expand Up @@ -140,7 +140,7 @@ export const compareDocuments: DocumentsCompare = async (
normalizedResult: false,
afterValueNormalizedProperty: AFTER_VALUE_NORMALIZED_PROPERTY,
beforeValueNormalizedProperty: BEFORE_VALUE_NORMALIZED_PROPERTY,
apiCompatibilityScopeFunction: createApihubApiCompatibilityScopeFunction(prevDocumentApiKind, currDocumentApiKind),
apiCompatibilityScopeFunction: createRestApiCompatibilityScopeFunction(prevDocumentApiKind, currDocumentApiKind),
openApiPathItemPerOperationDiffs: true,
},
) as { merged: OpenAPIV3.Document; diffs: Diff[] }
Expand Down
106 changes: 106 additions & 0 deletions src/components/compare/async.bwc.validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 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 { APIHUB_API_COMPATIBILITY_KIND_BWC, APIHUB_API_COMPATIBILITY_KIND_NO_BWC } from '../../consts'
import { isObject } from '../../utils'
import { JsonPath } from '@netcracker/qubership-apihub-json-crawl'
import {
API_COMPATIBILITY_KIND_BACKWARD_COMPATIBLE,
API_COMPATIBILITY_KIND_NOT_BACKWARD_COMPATIBLE,
ApiCompatibilityKind,
} from '@netcracker/qubership-apihub-api-diff'
import { getApiKindProperty } from '../document'
import { v3 as AsyncAPIV3 } from '@asyncapi/parser/esm/spec-types'
import { ApiCompatibilityScopeFunctionFactory } from './bwc.validation.types'

const ROOT_PATH_LENGTH = 0
const ASYNC_OPERATION_PATH_LENGTH = 2 // operations/<operationId>
const ASYNC_CHANNEL_PATH_LENGTH = 2 // channels/<channelId>

/**
* Creates an ApiCompatibilityScopeFunction for AsyncAPI documents.
*
* Resolution order:
* Channel (x-api-kind) → Operation (x-api-kind override) → Messages
* Document-level x-api-kind is not supported for AsyncAPI yet;
* prevDocumentApiKind/currDocumentApiKind params are reserved for future use.
*
* The scope function receives normalized before/after objects where $refs are resolved,
* so operation.channel is the resolved channel object with all its properties.
*
* - root: defaults to bwc (document-level api-kind reserved for future use)
* - operations/<operationId>: operation x-api-kind overrides channel x-api-kind
* - channels/<channelId>: channel's own x-api-kind
*/
export const createAsyncApiCompatibilityScopeFunction: ApiCompatibilityScopeFunctionFactory = (
prevDocumentApiKind = APIHUB_API_COMPATIBILITY_KIND_BWC,
currDocumentApiKind = APIHUB_API_COMPATIBILITY_KIND_BWC,
) => {
const defaultApiCompatibilityKind = (prevDocumentApiKind === APIHUB_API_COMPATIBILITY_KIND_NO_BWC || currDocumentApiKind === APIHUB_API_COMPATIBILITY_KIND_NO_BWC)
? API_COMPATIBILITY_KIND_NOT_BACKWARD_COMPATIBLE
: API_COMPATIBILITY_KIND_BACKWARD_COMPATIBLE

return (
path?: JsonPath,
beforeJso?: unknown,
afterJso?: unknown,
): ApiCompatibilityKind | undefined => {
const pathLength = path?.length ?? 0

if (pathLength === ROOT_PATH_LENGTH) {
return defaultApiCompatibilityKind
}

const firstSegment = path?.[0]
const beforeExists = isObject(beforeJso)
const afterExists = isObject(afterJso)

if (!beforeExists && !afterExists) {
return undefined
}

// operations/<operationId>: resolve api-kind from operation x-api-kind with channel fallback
if (firstSegment === 'operations' && pathLength === ASYNC_OPERATION_PATH_LENGTH) {
// In normalized documents, operation.channel is the resolved channel object
const beforeChannelKind = getApiKindProperty((beforeJso as AsyncAPIV3.OperationObject | undefined)?.channel)
const afterChannelKind = getApiKindProperty((afterJso as AsyncAPIV3.OperationObject | undefined)?.channel)

// Operation's own x-api-kind takes priority, falls back to channel's x-api-kind
const beforeOperationKind = getApiKindProperty(beforeJso, beforeChannelKind)
const afterOperationKind = getApiKindProperty(afterJso, afterChannelKind)

if (beforeOperationKind === APIHUB_API_COMPATIBILITY_KIND_NO_BWC || afterOperationKind === APIHUB_API_COMPATIBILITY_KIND_NO_BWC) {
return API_COMPATIBILITY_KIND_NOT_BACKWARD_COMPATIBLE
}

return API_COMPATIBILITY_KIND_BACKWARD_COMPATIBLE
}

// channels/<channelId>: use channel's own x-api-kind
if (firstSegment === 'channels' && pathLength === ASYNC_CHANNEL_PATH_LENGTH) {
const beforeChannelKind = getApiKindProperty(beforeJso)
const afterChannelKind = getApiKindProperty(afterJso)

if (beforeChannelKind === APIHUB_API_COMPATIBILITY_KIND_NO_BWC || afterChannelKind === APIHUB_API_COMPATIBILITY_KIND_NO_BWC) {
return API_COMPATIBILITY_KIND_NOT_BACKWARD_COMPATIBLE
}

return API_COMPATIBILITY_KIND_BACKWARD_COMPATIBLE
}

return undefined
}
}
7 changes: 7 additions & 0 deletions src/components/compare/bwc.validation.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ApihubApiCompatibilityKind } from '../../consts'
import { ApiCompatibilityScopeFunction } from '@netcracker/qubership-apihub-api-diff'

export type ApiCompatibilityScopeFunctionFactory = (
prevDocumentApiKind?: ApihubApiCompatibilityKind,
currDocumentApiKind?: ApihubApiCompatibilityKind,
) => ApiCompatibilityScopeFunction
Loading
Loading