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
1 change: 1 addition & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export const ErrorMessage = {
referenceNotAllowed: (ref: string) => `${JSON_SCHEMA_PROPERTY_REF} not allowed here: ${ref}`,
refNotFound: (ref: string) => `${JSON_SCHEMA_PROPERTY_REF} can't be resolved: ${ref}`,
refNotValidFormat: (ref: string) => `${JSON_SCHEMA_PROPERTY_REF} can't be parsed: ${ref}`,
duplicateParameter: (name: string, location: string) => `Duplicate parameter detected: name='${name}', in='${location}'`,
} as const
107 changes: 106 additions & 1 deletion src/origins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,111 @@ export function concatenateArraysInProperty(source: Jso, target: Jso, propertyKe
})
}

export function removeDuplicates<T>(
array: T[],
getUniqueKey: (item: T) => string | undefined,
onDuplicate?: (item: T, index: number, firstIndex: number) => void
): T[] {
const seen = new Map<string, number>() // key -> first index

for (let i = 0; i < array.length; i++) {
const item = array[i]
const key = getUniqueKey(item)

if (key === undefined) {
continue // Skip items without valid keys
}

const firstIndex = seen.get(key)
if (firstIndex !== undefined) {
// Duplicate found
onDuplicate?.(item, i, firstIndex)
delete array[i] // Create sparse array
} else {
seen.set(key, i)
}
}

return array // Returns sparse array
}

export function densify<T>(sparseArray: T[], originsFlag: symbol | undefined): T[] {
const dense: T[] = []
const originsRecord: OriginsMetaRecord = {}

for (let i = 0; i < sparseArray.length; i++) {
if (i in sparseArray) { // Check if index exists (not deleted)
const newIndex = dense.length
dense[newIndex] = sparseArray[i]

// Copy origins if they exist
if (originsFlag) {
const sourceOrigins = resolveOrigins(sparseArray, i, originsFlag)
if (sourceOrigins) {
originsRecord[newIndex] = sourceOrigins
}
}
}
}

// Set origins for the dense array
if (originsFlag && Object.keys(originsRecord).length > 0) {
setJsoProperty(dense as any, originsFlag, originsRecord)
}

return dense
}

export function addUniqueElements(
source: Jso,
target: Jso,
propertyKey: PropertyKey,
originsFlag: symbol | undefined,
getUniqueKey: (item: any) => string | undefined
): void {
const sourceArray = getJsoProperty(source, propertyKey)
if (!isArray(sourceArray)) {
return
}

let targetArray = getJsoProperty(target, propertyKey)
if (targetArray === undefined) {
targetArray = []
copyOrigins(source, target, propertyKey, propertyKey, originsFlag)
}

if (!isArray(targetArray)) {
return
}

setJsoProperty(target, propertyKey, targetArray)

// Build set of existing keys in target
const existingKeys = new Set<string>()
for (const item of targetArray) {
const key = getUniqueKey(item)
if (key !== undefined) {
existingKeys.add(key)
}
}

// Add source items that don't exist in target
for (let i = 0; i < sourceArray.length; i++) {
const item = sourceArray[i]
const key = getUniqueKey(item)

// Skip if already exists in target (operation overrides path) or key is undefined
if (key === undefined || existingKeys.has(key)) {
continue
}

// Add to target
const targetIndex = targetArray.length
targetArray[targetIndex] = item
copyOrigins(sourceArray, targetArray, i, targetIndex, originsFlag)
}
}

export function resolveOriginsMetaRecord(source: Jso, originsFlag: symbol | undefined): OriginsMetaRecord | undefined {
if (!originsFlag) {
return undefined
Expand Down Expand Up @@ -214,4 +319,4 @@ export function copyOriginsForArray(sourceArray: unknown[], targetArray: unknown

export const createSelfOriginsCloneHook: <T extends HasSelfOriginsResolver, R extends {}>(originsFlag: symbol | undefined) => SyncCloneHook<T, R> = <State extends HasSelfOriginsResolver, Rules extends {}>(originsFlag: symbol | undefined) => {
return createSelfMetaCloneHook<OriginLeafs, 'selfOriginResolver', State, Rules>('selfOriginResolver', originsFlag, [])
}
}
3 changes: 2 additions & 1 deletion src/rules/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import {
OPEN_API_PROPERTY_TAGS,
} from './openapi.const'

import { pathItemsUnification } from '../unifies/openapi'
import { pathItemsUnification, deduplicateParameters } from '../unifies/openapi'
import {
calculateHeaderName,
calculateHeaderPlace,
Expand Down Expand Up @@ -626,6 +626,7 @@ const openApiParametersRules = (version: OpenApiSpecVersion): NormalizationRules
hashOwner: true,
},
validate: checkType(TYPE_ARRAY),
unify: deduplicateParameters,
})

const openApiRequestRules = (version: OpenApiSpecVersion): NormalizationRules => ({
Expand Down
47 changes: 43 additions & 4 deletions src/unifies/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,53 @@
import { UnifyFunction } from '../types'
import { isObject } from '@netcracker/qubership-apihub-json-crawl'
import { isArray, isObject } from '@netcracker/qubership-apihub-json-crawl'
import { OpenAPIV3 } from 'openapi-types'
import { OPEN_API_HTTP_METHODS } from '../rules/openapi.const'
import { cleanSeveralOrigins, concatenateArraysInProperty, copyProperty } from '../origins'
import {
addUniqueElements,
cleanSeveralOrigins,
concatenateArraysInProperty,
copyProperty,
densify,
removeDuplicates,
} from '../origins'
import { ErrorMessage } from '../errors'
import PathItemObject = OpenAPIV3.PathItemObject
import ParameterObject = OpenAPIV3.ParameterObject

const EXTENSION_PREFIX = 'x-'

const getParameterUniqueKey = (param: ParameterObject): string | undefined => {
if (!isObject(param)) {
return undefined
}
const name = param.name
const inValue = param.in
if (name !== undefined && inValue !== undefined) {
return `${name}:${inValue}`
}
return undefined
}

export const deduplicateParameters: UnifyFunction = (jso, ctx) => {
if (!isArray(jso)) {
return jso
}

const sparseArray = removeDuplicates(
jso,
getParameterUniqueKey,
(item, index, firstIndex) => {
// Report duplicate error
const message = ErrorMessage.duplicateParameter(item?.name, item?.in)
ctx.options.onUnifyError?.(message, ctx.path, jso, new Error(message))
}
)

return densify(sparseArray, ctx.options.originsFlag)
}

export const pathItemsUnification: UnifyFunction = (value, { options }) => {
if (!isObject(value)) { return value}
if (!isObject(value)) { return value }
const pathItem: PathItemObject = value
const {
parameters: pathParameters,
Expand Down Expand Up @@ -41,7 +80,7 @@ export const pathItemsUnification: UnifyFunction = (value, { options }) => {
}
result[method] = operation //origin copy already.
// Deep copy not allowed!!! So only mutations :(
concatenateArraysInProperty(pathItem, operation, 'parameters', options.originsFlag)
addUniqueElements(pathItem, operation, 'parameters', options.originsFlag, getParameterUniqueKey)
concatenateArraysInProperty(pathItem, operation, 'servers', options.originsFlag)
if (operation.summary === undefined && pathSummary !== undefined) {
copyProperty(pathItem, operation, 'summary', options.originsFlag)
Expand Down
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,4 +383,4 @@ export const removeDuplicatesWithMergeOrigins = <T>(array: T[], originFlag: symb
setOriginsForArray(uniqueItems, originFlag, itemOrigins)

return uniqueItems
}
}
12 changes: 8 additions & 4 deletions test/oas/bugs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { TEST_DEFAULTS_FLAG, TEST_HASH_FLAG, TEST_SYNTHETIC_ALL_OF_FLAG } from '
import { denormalize, JSON_SCHEMA_PROPERTY_DEPRECATED, normalize, OriginLeafs, resolveOrigins } from '../../src'
import outOfMemoryCauseTooManyCombinations from '../resources/out-of-memory-cause-too-many-combinations.json'
import bugWithWrongOrigins from '../resources/bug-with-wrong-origins.json'
import bugWithSpearedArray from '../resources/bug-with-speared-array.json'
import bugWithSparseArray from '../resources/bug-with-sparse-array.json'
import { TEST_INLINE_REFS_FLAG, TEST_ORIGINS_FLAG, TEST_ORIGINS_FOR_DEFAULTS, TEST_SYNTHETIC_TITLE_FLAG } from '../helpers'
import { isObject, syncCrawl } from '@netcracker/qubership-apihub-json-crawl'
import 'jest-extended'
Expand Down Expand Up @@ -239,8 +239,8 @@ describe('Bugs', () => {
expect(result.paths['customer'].get.parameters[0].schema).not.toHaveProperty([TEST_INLINE_REFS_FLAG])
})

it('speared array', () => {
const result = normalize(bugWithSpearedArray, {
it('sparse array', () => {
const result = normalize(bugWithSparseArray, {
validate: true,
liftCombiners: true,
unify: true,
Expand All @@ -250,6 +250,10 @@ describe('Bugs', () => {
hashFlag: TEST_HASH_FLAG,
inlineRefsFlag: TEST_INLINE_REFS_FLAG,
}) as any
expect(result).toHaveProperty(['paths', '/datasets/events', 'get', 'parameters', 'length'], 10) /*1 and 3 index will be missing*/

expect(result).toHaveProperty(['paths', '/datasets/events', 'get', 'parameters', 'length'], 8)
// Verify all parameters are valid objects
const params = result.paths['/datasets/events'].get.parameters
expect(params.every((p: any) => p && typeof p === 'object')).toBe(true)
})
})
39 changes: 39 additions & 0 deletions test/oas/unify.origins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,4 +273,43 @@ describe('unify origins', function () {
})

})

it('should preserve origins when unifying path item and deduplicating operation parameters', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'test': {
parameters: [
{ name: 'id', in: 'query', schema: { type: 'string' } },
{ name: 'id', in: 'query', schema: { type: 'string' } }, // duplicate
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
],
get: {
parameters: [
{ name: 'offset', in: 'query', schema: { type: 'string' } },
{ name: 'offset', in: 'query', schema: { type: 'string' } }, // duplicate
{ name: 'maxItems', in: 'query', schema: { type: 'integer' } },
],
responses: {
'200': { description: 'OK' },
},
},
},
},
}

const result = normalize(spec, OPTIONS) as any

// Should have 2 parameters after deduplication
const params = result.paths?.['test']?.get?.parameters
expect(params).toHaveLength(4)

commonOriginsCheck(result, { source: spec })
const resultWithHmr = convertOriginToHumanReadable(result, TEST_ORIGINS_FLAG)
expect(resultWithHmr).toHaveProperty(['paths', 'test', 'get', 'parameters', TEST_ORIGINS_FLAG, 0], ['paths/test/get/parameters/0'])
expect(resultWithHmr).toHaveProperty(['paths', 'test', 'get', 'parameters', TEST_ORIGINS_FLAG, 1], ['paths/test/get/parameters/2'])
expect(resultWithHmr).toHaveProperty(['paths', 'test', 'get', 'parameters', TEST_ORIGINS_FLAG, 2], ['paths/test/parameters/0'])
expect(resultWithHmr).toHaveProperty(['paths', 'test', 'get', 'parameters', TEST_ORIGINS_FLAG, 3], ['paths/test/parameters/2'])
})
})
Loading
Loading