Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1f9b49e
feat: Added pathItems
makeev-pavel Aug 20, 2025
050f7ae
feat: Added work with '$ref' in rest operation
makeev-pavel Aug 22, 2025
5661ae0
feat: Fixed merge pathItems operations
makeev-pavel Aug 25, 2025
df04f59
feat: Added tests
makeev-pavel Aug 25, 2025
37369ef
feat: refactoring
makeev-pavel Aug 26, 2025
63690cf
Merge branch 'develop' into feature/pathItems
makeev-pavel Aug 26, 2025
0c2cb21
feat: Added tests and review
makeev-pavel Aug 29, 2025
45225cf
feat: Added tests and review
makeev-pavel Sep 1, 2025
e3cbfa9
Merge branch 'develop' into feature/pathItems
makeev-pavel Sep 1, 2025
1caef30
feat: Refactoring
makeev-pavel Sep 3, 2025
30097c2
feat: Refactoring
makeev-pavel Sep 5, 2025
700ba7e
feat: Update tests
makeev-pavel Sep 5, 2025
225d22a
feat: Update dep
makeev-pavel Sep 5, 2025
bd3ad86
feat: Refactoring
makeev-pavel Sep 8, 2025
aa6f46a
feat: Added hash tests
makeev-pavel Sep 9, 2025
26cc1fd
feat: Fixed servers prefix for operations and added tests
makeev-pavel Sep 10, 2025
136d5ce
feat: Refactoring
makeev-pavel Sep 10, 2025
ffdc348
feat: Refactoring
makeev-pavel Sep 12, 2025
11f601d
feat: Refactoring
makeev-pavel Sep 12, 2025
793e0bc
feat: POC ref pathItems support
makeev-pavel Sep 12, 2025
c00bce3
feat: refactoring
makeev-pavel Sep 15, 2025
6203ae9
feat: typing
makeev-pavel Sep 15, 2025
665fec8
feat: Added cleaning PathItemObject in components
makeev-pavel Sep 15, 2025
66af9d8
feat: Cleaning
makeev-pavel Sep 15, 2025
f38895a
feat: Cleaning
makeev-pavel Sep 15, 2025
9dacc6a
Merge pull request #40 from Netcracker/feature/pathItems-test
makeev-pavel Sep 15, 2025
0a44196
feat: Update tests
makeev-pavel Sep 16, 2025
14ad3fa
feat: Update tests
makeev-pavel Sep 16, 2025
c4db313
Merge remote-tracking branch 'origin/feature/pathItems' into feature/…
makeev-pavel Sep 16, 2025
5b1b4e5
feat: Review
makeev-pavel Sep 17, 2025
21672c6
feat: Cleaning
makeev-pavel Sep 17, 2025
1d5b5d5
Merge branch 'develop' into feature/pathItems
makeev-pavel Sep 17, 2025
cacf54c
feat: Added info in yaml tests
makeev-pavel Sep 17, 2025
2d5d97f
feat: Fix linter
makeev-pavel Sep 17, 2025
d4b058e
feat: Linter
makeev-pavel Sep 17, 2025
9bf11d7
feat: Linter
makeev-pavel Sep 17, 2025
bbefe7e
feat: Review
makeev-pavel Sep 18, 2025
1ae91d7
feat: Review
makeev-pavel Sep 18, 2025
1c4e293
feat: Review
makeev-pavel Sep 18, 2025
a12e958
feat: Linter
makeev-pavel Sep 18, 2025
cfeeb6d
Merge branch 'develop' into feature/pathItems
makeev-pavel Sep 18, 2025
7f67976
feat: Review
makeev-pavel Sep 19, 2025
c70196e
feat: Review
makeev-pavel Sep 19, 2025
0f1f89d
feat: Review
makeev-pavel Sep 19, 2025
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 @@ -31,8 +31,8 @@
"update-lock-file": "update-lock-file @netcracker"
},
"dependencies": {
"@netcracker/qubership-apihub-api-diff": "2.2.0",
"@netcracker/qubership-apihub-api-unifier": "2.3.0",
"@netcracker/qubership-apihub-api-diff": "feature-pathItems",
"@netcracker/qubership-apihub-api-unifier": "feature-pathItems",
"@netcracker/qubership-apihub-graphapi": "1.0.8",
"@netcracker/qubership-apihub-json-crawl": "1.0.4",
"adm-zip": "0.5.10",
Expand Down
50 changes: 46 additions & 4 deletions src/apitypes/rest/rest.operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
} from '@netcracker/qubership-apihub-api-unifier'
import { calculateObjectHash } from '../../utils/hashes'
import { calculateTolerantHash } from '../../components/deprecated'
import { getValueByPath } from '../../utils/path'

export const buildRestOperation = (
operationId: string,
Expand Down Expand Up @@ -257,19 +258,60 @@ const createSingleOperationSpec = (
): TYPE.RestOperationData => {
const pathData = document.paths[path] as OpenAPIV3.PathItemObject

return {
const resolveRefPathItem = (ref: string): OpenAPIV3.PathItemObject | null => {
if (!ref) {
return null
}
const { jsonPath } = parseRef(ref)
const target = getValueByPath(document, jsonPath) as OpenAPIV3.PathItemObject
if (!target || typeof target !== 'object') {
return null
}
return {
...extractCommonPathItemProperties(target),
[method]: { ...target[method] },
}
}

const buildComponentsFromRef = (ref: string): OpenAPIV3.ComponentsObject => {
const resolved = resolveRefPathItem(ref)
if (!resolved) {return {}}
const { jsonPath } = parseRef(ref)
const container: any = {}
setValueByPath(container, jsonPath, resolved)
return container.components || {}
}

const specBase = {
openapi: openapi ?? '3.0.0',
...takeIfDefined({ servers }),
...takeIfDefined({ security }), // TODO: remove duplicates in security
components: {
...takeIfDefined({ securitySchemes }),
},
}

if (pathData && '$ref' in pathData && pathData.$ref) {
return {
...specBase,
paths: {
[path]: pathData,
},
components: {
...specBase.components,
...buildComponentsFromRef(pathData.$ref ?? ''),
},
}
}

return {
...specBase,
paths: {
[path]: {
...extractCommonPathItemProperties(pathData),
[method]: { ...pathData[method] },
},
},
components: {
...takeIfDefined({ securitySchemes }),
},
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/apitypes/rest/rest.operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const buildRestOperations: OperationsBuilder<OpenAPIV3.Document> = async
debugCtx,
)

const { paths, servers } = document.data
const { paths, servers } = effectiveDocument

const operations: TYPE.VersionRestOperation[] = []
if (!paths) { return [] }
Expand Down
124 changes: 88 additions & 36 deletions src/strategies/document-group.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,25 +37,19 @@ import { OpenAPIV3 } from 'openapi-types'
import { getOperationBasePath } from '../apitypes/rest/rest.utils'
import { VersionRestDocument } from '../apitypes/rest/rest.types'
import { FILE_FORMAT_JSON, INLINE_REFS_FLAG, NORMALIZE_OPTIONS } from '../consts'
import { normalize } from '@netcracker/qubership-apihub-api-unifier'
import { normalize, parseRef } from '@netcracker/qubership-apihub-api-unifier'
import { calculateSpecRefs, extractCommonPathItemProperties } from '../apitypes/rest/rest.operation'
import { getValueByPath } from '../utils/path'

function getTransformedDocument(document: ResolvedGroupDocument, format: FileFormat, packages: ResolvedReferenceMap): VersionRestDocument {
const versionDocument = toVersionDocument(document, format)

const source = extractDocumentData(versionDocument)
const sourceDocument = extractDocumentData(versionDocument)
versionDocument.data = transformDocumentData(versionDocument)
const normalizedDocument = normalize(
versionDocument.data,
{
...NORMALIZE_OPTIONS,
inlineRefsFlag: INLINE_REFS_FLAG,
source,
},
) as OpenAPIV3.Document
const normalizedDocument = normalizeOpenApi(versionDocument.data, sourceDocument)
versionDocument.publish = true

calculateSpecRefs(source, normalizedDocument, versionDocument.data)
calculateSpecRefs(sourceDocument, normalizedDocument, versionDocument.data)

// dashboard case
if (document.packageRef) {
Expand Down Expand Up @@ -116,48 +110,106 @@ function extractDocumentData(versionDocument: VersionDocument): OpenAPIV3.Docume
}

function transformDocumentData(versionDocument: VersionDocument): OpenAPIV3.Document {
const sourceDocument = extractDocumentData(versionDocument)
const normalizedDocument = normalizeOpenApi(sourceDocument)

const { paths: sourcePaths, components: sourceComponents, ...restOfSource } = sourceDocument
const { paths: normalizedPaths } = normalizedDocument

const documentData = extractDocumentData(versionDocument)
const { paths, components, ...rest } = documentData
const result: OpenAPIV3.Document = {
...rest,
const resultDocument: OpenAPIV3.Document = {
...restOfSource,
paths: {},
}

for (const path of Object.keys(paths)) {
const pathData = paths[path]
if (typeof pathData !== 'object' || !pathData) {
for (const path of Object.keys(normalizedPaths)) {
const sourcePathItem = sourcePaths[path]
const normalizedPathItem = normalizedPaths[path]

if (!isNonNullObject(sourcePathItem) || !isNonNullObject(normalizedPathItem)) {
continue
}
const commonPathItemProperties = extractCommonPathItemProperties(pathData)

for (const method of Object.keys(pathData)) {
const inferredMethod = method as OpenAPIV3.HttpMethods
const commonPathProps = extractCommonPathItemProperties(sourcePathItem)
const pathItemRef = sourcePathItem?.$ref

// check if field is a valid openapi http method defined in OpenAPIV3.HttpMethods
if (!Object.values(OpenAPIV3.HttpMethods).includes(inferredMethod)) {
for (const method of Object.keys(normalizedPathItem)) {
const inferredMethod = method as OpenAPIV3.HttpMethods
if (!isValidHttpMethod(inferredMethod)) {
continue
}

const methodData = pathData[inferredMethod]
const basePath = getOperationBasePath(methodData?.servers || pathData?.servers || documentData?.servers || [])
const methodData = normalizedPathItem[inferredMethod]
const basePath = getOperationBasePath(methodData?.servers || sourcePathItem?.servers || sourcePathItem?.servers || [])
const operationPath = basePath + path

const operationId = slugify(`${removeFirstSlash(operationPath)}-${method}`)

if (versionDocument.operationIds.includes(operationId)) {
const pathData = documentData.paths[path]!
result.paths[path] = {
...result.paths[path],
...commonPathItemProperties,
[inferredMethod]: { ...pathData[inferredMethod] },
}
result.components = {
...takeIfDefined({ securitySchemes: components?.securitySchemes }),
}
if (!versionDocument.operationIds.includes(operationId)) {
continue
}

const updatedPathItem = buildPath(
sourceDocument,
path,
inferredMethod,
commonPathProps,
pathItemRef,
)

resultDocument.paths[path] = {
...(resultDocument.paths[path] || {}),
...updatedPathItem,
}
resultDocument.components = {
...takeIfDefined({ securitySchemes: sourceComponents?.securitySchemes }),
}
}
}

return result
return resultDocument
}

function normalizeOpenApi(document: OpenAPIV3.Document, source?: OpenAPIV3.Document): OpenAPIV3.Document {
return normalize(
document,
{
...NORMALIZE_OPTIONS,
inlineRefsFlag: INLINE_REFS_FLAG,
...(source ? { source } : {}),
},
) as OpenAPIV3.Document
}

function isValidHttpMethod(method: string): method is OpenAPIV3.HttpMethods {
return (Object.values(OpenAPIV3.HttpMethods) as string[]).includes(method)
}

function isNonNullObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

function buildPath(
sourceDocument: OpenAPIV3.Document,
path: string,
method: OpenAPIV3.HttpMethods,
commonPathProps: Partial<OpenAPIV3.PathItemObject>,
pathItemRef?: string,
): OpenAPIV3.PathItemObject {
if (!pathItemRef) {
const originalPathItem = sourceDocument.paths[path]!
return {
...commonPathProps,
[method]: { ...originalPathItem[method] },
} as OpenAPIV3.PathItemObject
}

const { jsonPath } = parseRef(pathItemRef)
const targetPathItem = getValueByPath(sourceDocument, jsonPath) as OpenAPIV3.PathItemObject
if (!targetPathItem) return {}

const originalPathItem = sourceDocument.paths[path]!
return {
...(originalPathItem),
...commonPathProps,
}
}
3 changes: 3 additions & 0 deletions src/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,6 @@ export function areDeclarationPathsEqual(firstItemDeclarationPaths: JsonPath[],

return true
}

export const getValueByPath = (value: any, path: JsonPath): any => path.reduce((data, key) => data[key], value)

6 changes: 6 additions & 0 deletions test/declarative-changes-in-rest-operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ describe('Number of declarative changes in rest operation test', () => {
expect(result).toEqual(numberOfImpactedOperationsMatcher({ [BREAKING_CHANGE_TYPE]: 1 }))
})

test('Multiple use of one schema in another schema which is used in response pathItems', async () => {
const result = await buildChangelogPackage('declarative-changes-in-rest-operation/case8')
expect(result).toEqual(changesSummaryMatcher({ [BREAKING_CHANGE_TYPE]: 1 }))
expect(result).toEqual(numberOfImpactedOperationsMatcher({ [BREAKING_CHANGE_TYPE]: 1 }))
})

test('Multiple use of one schema in both request and response (different severity)', async () => {
const result = await buildChangelogPackage('declarative-changes-in-rest-operation/case3')
expect(result).toEqual(changesSummaryMatcher({
Expand Down
77 changes: 65 additions & 12 deletions test/document-group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,10 @@ describe('Document Group test', () => {
})

test('should have properly merged documents', async () => {
const pkg = LocalRegistry.openPackage('merge-operations', groupToOperationIdsMap)
const editor = await Editor.openProject(pkg.packageId, pkg)

await pkg.publish(pkg.packageId, { packageId: pkg.packageId })
const result = await editor.run({
packageId: pkg.packageId,
buildType: BUILD_TYPE.MERGED_SPECIFICATION,
groupName: GROUP_NAME,
apiType: REST_API_TYPE,
})
const expectedResult = load((await loadFileAsString(pkg.projectsDir, pkg.packageId, EXPECTED_RESULT_FILE))!)
expect(result.merged?.data).toEqual(expectedResult)
await runMergeOperationsCase('case1')
})


test('should rename documents with matching names', async () => {
const dashboard = LocalRegistry.openPackage('documents-collision', groupToOperationIdsMap2)
const package1 = LocalRegistry.openPackage('documents-collision/package1')
Expand Down Expand Up @@ -90,4 +80,67 @@ describe('Document Group test', () => {
expect(Object.keys(document.data.paths).length).toEqual(document.operationIds.length)
}
})

describe('PathItems tests', () => {
test('should have documents with keep pathItems in components', async () => {
const pkg = LocalRegistry.openPackage('document-group/case1', groupToOperationIdsMap)
const editor = await Editor.openProject(pkg.packageId, pkg)
await pkg.publish(pkg.packageId, { packageId: pkg.packageId })

const result = await editor.run({
packageId: pkg.packageId,
groupName: GROUP_NAME,
buildType: BUILD_TYPE.REDUCED_SOURCE_SPECIFICATIONS,
})

for (const document of Array.from(result.documents.values())) {
expect(Object.keys(document.data.components.pathItems).length).toEqual(document.operationIds.length)
}
})

test('should have documents stripped of operations other than from provided group', async () => {
const pkg = LocalRegistry.openPackage('document-group/case2', groupToOperationIdsMap)
const editor = await Editor.openProject(pkg.packageId, pkg)
await pkg.publish(pkg.packageId, { packageId: pkg.packageId })

const result = await editor.run({
packageId: pkg.packageId,
groupName: GROUP_NAME,
buildType: BUILD_TYPE.REDUCED_SOURCE_SPECIFICATIONS,
})
for (const document of Array.from(result.documents.values())) {
expect(Object.keys(document.data.paths).length).toEqual(document.operationIds.length)
}
})

describe('Merge Operations', () => {
test('should have properly merged documents', async () => {
await runMergeOperationsCase('case2')
})

test('should have properly merged documents mixed formats (operation + pathItems operation)', async () => {
await runMergeOperationsCase('case3')
})
})
})

async function runMergeOperationsCase(caseName: string): Promise<void> {
const pkg = LocalRegistry.openPackage(`merge-operations/${caseName}`, groupToOperationIdsMap)
const editor = await Editor.openProject(pkg.packageId, pkg)

await pkg.publish(pkg.packageId, { packageId: pkg.packageId })

const result = await editor.run({
packageId: pkg.packageId,
buildType: BUILD_TYPE.MERGED_SPECIFICATION,
groupName: GROUP_NAME,
apiType: REST_API_TYPE,
})

const expectedResult = load(
(await loadFileAsString(pkg.projectsDir, pkg.packageId, EXPECTED_RESULT_FILE))!,
)

expect(result.merged?.data).toEqual(expectedResult)
}
})
13 changes: 10 additions & 3 deletions test/merge/openapi/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,19 @@ describe('Merge openapi schemas', () => {
})

test('Should throw on different methods content', async () => {
// 1: [[../should-throw-on-different-methods/1.yaml]]
// 2: [[../should-throw-on-different-methods/2.yaml]]
const testId = 'should-throw-on-different-methods'
// 1: [[../should-throw-on-different-methods/case1/1.yaml]]
// 2: [[../should-throw-on-different-methods/case1/2.yaml]]
const testId = 'should-throw-on-different-methods/case1'
await expect(getTestData(testId)).rejects.toThrowError(/paths.\/path1/)
})

test('Should throw on different pathItems methods content', async () => {
// 1: [[../should-throw-on-different-methods/case2/1.yaml]]
// 2: [[../should-throw-on-different-methods/case2/2.yaml]]
const testId = 'should-throw-on-different-methods/case2'
await expect(getTestData(testId)).rejects.toThrowError(/components.pathItems.path1/)
})

test('Should merge with empty template', async () => {
// 1: [[../should-merge-with-empty-template/1.yaml]]
// 2: [[../should-merge-with-empty-template/2.yaml]]
Expand Down
Loading
Loading