Skip to content
Merged
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
93 changes: 72 additions & 21 deletions src/openapi/openapi3.mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,38 +92,89 @@ export const contentMediaTypeMappingResolver: MappingResolver<string> = (before,
const result: MapKeysResult<string> = { added: [], removed: [], mapped: {} }

const beforeKeys = objectKeys(before)
const _beforeKeys = beforeKeys.map((key) => key.split(';')[0] ?? '')
const afterKeys = objectKeys(after)
const _afterKeys = afterKeys.map((key) => key.split(';')[0] ?? '')

const mappedIndex = new Set(afterKeys.keys())

for (let i = 0; i < beforeKeys.length; i++) {
const _afterIndex = _afterKeys.findIndex((key) => {
const [afterType, afterSubType] = key.split('/')
const [beforeType, beforeSubType] = _beforeKeys[i].split('/')
const unmappedAfterIndices = new Set(afterKeys.keys())
const unmappedBeforeIndices = new Set(beforeKeys.keys())

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) {
const afterKey = getComparisonKey(afterKeys[afterIndex])
if (afterKey === beforeKey) {
matchingAfterIndex = afterIndex
break
}
}

if (afterType !== beforeType && afterType !== '*' && beforeType !== '*') { return false }
if (afterSubType !== beforeSubType && afterSubType !== '*' && beforeSubType !== '*') { return false }
return true
})
if (matchingAfterIndex !== undefined) {
// Match found - create mapping and remove from unmapped sets
result.mapped[beforeKeys[beforeIndex]] = afterKeys[matchingAfterIndex]
unmappedAfterIndices.delete(matchingAfterIndex)
unmappedBeforeIndices.delete(beforeIndex)
}
}
}

if (_afterIndex < 0 || !mappedIndex.has(_afterIndex)) {
// removed item
result.removed.push(beforeKeys[i])
} else {
// mapped items
result.mapped[beforeKeys[i]] = afterKeys[_afterIndex]
mappedIndex.delete(_afterIndex)
// First, map exact matches for full media type
mapExactMatches((key) => key)

// After that, try to map media types by base type for remaining unmapped keys
mapExactMatches(getMediaTypeBase)

// If exactly one unmapped item in both before and after, try wildcard matching
if (unmappedBeforeIndices.size === 1 && unmappedAfterIndices.size === 1) {
const beforeIndex = Array.from(unmappedBeforeIndices)[0]
const afterIndex = Array.from(unmappedAfterIndices)[0]
const beforeKey = beforeKeys[beforeIndex]
const afterKey = afterKeys[afterIndex]
const beforeBaseType = getMediaTypeBase(beforeKey)
const afterBaseType = getMediaTypeBase(afterKey)

// Check if they are compatible using wildcard matching
if (isWildcardCompatible(beforeBaseType, afterBaseType)) {
// Map them together
result.mapped[beforeKeys[beforeIndex]] = afterKeys[afterIndex]
unmappedAfterIndices.delete(afterIndex)
unmappedBeforeIndices.delete(beforeIndex)
}
}

// added items
mappedIndex.forEach((i) => result.added.push(afterKeys[i]))
// Mark remaining unmapped items as removed/added
unmappedBeforeIndices.forEach((index) => result.removed.push(beforeKeys[index]))
unmappedAfterIndices.forEach((index) => result.added.push(afterKeys[index]))

return result
}

function getMediaTypeBase(mediaType: string): string {
return mediaType.split(';')[0] ?? ''
}

function isWildcardCompatible(beforeType: string, afterType: string): boolean {
const [beforeMainType, beforeSubType] = beforeType.split('/')
const [afterMainType, afterSubType] = afterType.split('/')

// Check main type compatibility
if (beforeMainType !== afterMainType && beforeMainType !== '*' && afterMainType !== '*') {
return false
}

// Check sub type compatibility
if (beforeSubType !== afterSubType && beforeSubType !== '*' && afterSubType !== '*') {
return false
}

return true
}

export function hidePathParamNames(path: string): string {
return path.replace(PATH_PARAMETER_REGEXP, PATH_PARAM_UNIFIED_PLACEHOLDER)
}
Expand Down
33 changes: 22 additions & 11 deletions test/bugs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import changeToNothingClassificationAfter from './helper/resources/change-to-not
import spearedParamsBefore from './helper/resources/speared-parameters/before.json'
import spearedParamsAfter from './helper/resources/speared-parameters/after.json'

import wildcardContentSchemaMediaTypeCombinedWithSpecificMediaTypeBefore from './helper/resources/wildcard-content-schema-media-type-combined-with-specific-media-type/before.json'
import wildcardContentSchemaMediaTypeCombinedWithSpecificMediaTypeAfter from './helper/resources/wildcard-content-schema-media-type-combined-with-specific-media-type/after.json'

import { diffsMatcher } from './helper/matchers'
import { TEST_DIFF_FLAG, TEST_ORIGINS_FLAG } from './helper'
import { JSON_SCHEMA_NODE_SYNTHETIC_TYPE_NOTHING } from '@netcracker/qubership-apihub-api-unifier'
Expand Down Expand Up @@ -97,16 +100,7 @@ describe('Real Data', () => {
const after: any = infinityAfter
const { diffs } = apiDiff(before, after, OPTIONS)
const responseContentPath = ['paths', '/api/v1/dictionaries/dictionary/item', 'get', 'responses', '200', 'content']
expect(diffs).toEqual(diffsMatcher([
expect.objectContaining({
beforeDeclarationPaths: [[...responseContentPath, '*/*']],
afterDeclarationPaths: [[...responseContentPath, 'application/json']],
action: DiffAction.rename,
beforeKey: '*/*',
afterKey: 'application/json',
type: nonBreaking,
scope: 'response',
}),
expect(diffs).toEqual(diffsMatcher([
expect.objectContaining({
afterDeclarationPaths: [['components', 'schemas', 'DictionaryItem', 'x-entity']],
afterValue: 'DictionaryItem',
Expand Down Expand Up @@ -210,10 +204,27 @@ describe('Real Data', () => {
}),
]))
})
it('spered parameters', () => {

it('speared parameters', () => {
const before: any = spearedParamsBefore
const after: any = spearedParamsAfter
const { diffs } = apiDiff(before, after, OPTIONS)
expect(diffs).toBeEmpty()
})

// The original issue was that media type was reported as added/removed, when nothing actually changed
it('wildcard content schema media type in combination with specific media type', () => {
const before: any = wildcardContentSchemaMediaTypeCombinedWithSpecificMediaTypeBefore
const after: any = wildcardContentSchemaMediaTypeCombinedWithSpecificMediaTypeAfter
const { diffs } = apiDiff(before, after, OPTIONS)

expect(diffs).toEqual(diffsMatcher([
expect.objectContaining({
action: DiffAction.replace,
beforeDeclarationPaths: [['servers', 0, 'url']],
afterDeclarationPaths: [['servers', 0, 'url']],
type: annotation,
}),
]))
})
})
2 changes: 1 addition & 1 deletion test/helper/resources/ref-with-array-to-self/after.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"200": {
"description": "OK",
"content": {
"application/json": {
"*/*": {
"schema": {
"type": "array",
"items": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"openapi": "3.0.1",
"servers": [
{
"url": "http://config-server.cbss-kue-coe-dv.cl.local:1111",
"description": "Generated server url"
}
],
"paths": {
"/path1": {
"get": {
"operationId": "retrieve_1",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string"
}
},
"application/octet-stream": {
"schema": {
"type": "string",
"format": "byte"
}
}
}
},
"404": {
"description": "Not Found"
}
}
}
}
},
"components": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"openapi": "3.0.1",
"servers": [
{
"url": "http://config-server:1111",
"description": "Generated server url"
}
],
"paths": {
"/path1": {
"get": {
"operationId": "retrieve_1",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string"
}
},
"application/octet-stream": {
"schema": {
"type": "string",
"format": "byte"
}
}
}
},
"404": {
"description": "Not Found"
}
}
}
}
},
"components": {}
}
Loading