diff --git a/package.json b/package.json index 4692c43..4b4a534 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/constants.ts b/src/core/constants.ts index 398e827..6a47f9b 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -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') diff --git a/src/index.ts b/src/index.ts index 2004574..11df071 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -24,4 +33,10 @@ export { isDiffRename, isDiffReplace, } from './utils' -export { onlyExistedArrayIndexes } from './utils' + +export { + aggregateDiffsWithRollup, + extractOperationBasePath, + onlyExistedArrayIndexes +} from './utils' + diff --git a/src/openapi/openapi3.classify.ts b/src/openapi/openapi3.classify.ts index f65c3ea..ac12a6e 100644 --- a/src/openapi/openapi3.classify.ts +++ b/src/openapi/openapi3.classify.ts @@ -5,8 +5,8 @@ import { breakingIfAfterTrue, nonBreaking, PARENT_JUMP, - strictResolveValueFromContext, reverseClassifyRule, + strictResolveValueFromContext, transformClassifyRule, unclassified, } from '../core' @@ -14,7 +14,8 @@ 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 }) => { @@ -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 - } + }, ] diff --git a/src/openapi/openapi3.mapping.ts b/src/openapi/openapi3.mapping.ts index 2587250..714d749 100644 --- a/src/openapi/openapi3.mapping.ts +++ b/src/openapi/openapi3.mapping.ts @@ -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 = (before, after) => { @@ -23,32 +32,61 @@ export const singleOperationPathMappingResolver: MappingResolver = (befo return result } -export const pathMappingResolver: MappingResolver = (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} 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 = (before, after, ctx) => { const result: MapKeysResult = { 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 = (before, after) => { + + const result: MapKeysResult = { 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 } @@ -100,10 +138,10 @@ export const contentMediaTypeMappingResolver: MappingResolver = (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) { @@ -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) } diff --git a/src/openapi/openapi3.rules.ts b/src/openapi/openapi3.rules.ts index 51652f5..6ca79bb 100644 --- a/src/openapi/openapi3.rules.ts +++ b/src/openapi/openapi3.rules.ts @@ -54,6 +54,7 @@ import { } from './openapi3.classify' import { contentMediaTypeMappingResolver, + methodMappingResolver, paramMappingResolver, pathMappingResolver, singleOperationPathMappingResolver, @@ -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], diff --git a/src/utils.ts b/src/utils.ts index 3e645d1..afa53f1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 => { return typeof value === 'object' && value !== null @@ -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 | undefined { + + const visited = new Set() + + function _aggregateDiffsWithRollup(obj: any): Set | undefined { + if (!isObject(obj)) { + return undefined + } + + if (visited.has(obj)) { + return obj[aggregatedDiffProperty] as Set | undefined + } + + visited.add(obj) + + // Process all children and collect their diffs + const childrenDiffs = new Array>() + 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() + for (const childDiffs of childrenDiffs) { + childDiffs.forEach(diff => aggregatedDiffs.add(diff)) + } + const diffs = obj[diffProperty] as Record + for (const key in diffs) { + aggregatedDiffs.add(diffs[key]) + } + // 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 | 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 '' + } +} diff --git a/test/helper/index.ts b/test/helper/index.ts index 04a88bc..0db461b 100644 --- a/test/helper/index.ts +++ b/test/helper/index.ts @@ -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') diff --git a/test/helper/resources/mixed-case-with-method-prefix-override/after.yaml b/test/helper/resources/mixed-case-with-method-prefix-override/after.yaml new file mode 100644 index 0000000..29ac3fa --- /dev/null +++ b/test/helper/resources/mixed-case-with-method-prefix-override/after.yaml @@ -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 diff --git a/test/helper/resources/mixed-case-with-method-prefix-override/before.yaml b/test/helper/resources/mixed-case-with-method-prefix-override/before.yaml new file mode 100644 index 0000000..bfc17dc --- /dev/null +++ b/test/helper/resources/mixed-case-with-method-prefix-override/before.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: test + version: 0.1.0 +servers: + - url: https://example1.com/api/v1 +paths: + /changed1: + get: + responses: + '200': + description: a1 diff --git a/test/helper/resources/path-prefix-operation-server-to-path/after.yaml b/test/helper/resources/path-prefix-operation-server-to-path/after.yaml new file mode 100644 index 0000000..b9f7813 --- /dev/null +++ b/test/helper/resources/path-prefix-operation-server-to-path/after.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com +paths: + /api/v1/users: + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-operation-server-to-path/before.yaml b/test/helper/resources/path-prefix-operation-server-to-path/before.yaml new file mode 100644 index 0000000..ea5ca9c --- /dev/null +++ b/test/helper/resources/path-prefix-operation-server-to-path/before.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com +paths: + /users: + get: + summary: Get users + servers: + - url: https://example.com/api/v1 + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-path-item-priority/after.yaml b/test/helper/resources/path-prefix-path-item-priority/after.yaml new file mode 100644 index 0000000..684711d --- /dev/null +++ b/test/helper/resources/path-prefix-path-item-priority/after.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +paths: + /api/v1/users: + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-path-item-priority/before.yaml b/test/helper/resources/path-prefix-path-item-priority/before.yaml new file mode 100644 index 0000000..03b268f --- /dev/null +++ b/test/helper/resources/path-prefix-path-item-priority/before.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com/api/v2 +paths: + /users: + servers: + - url: https://example.com/api/v1 + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-path-item-server-to-path/after.yaml b/test/helper/resources/path-prefix-path-item-server-to-path/after.yaml new file mode 100644 index 0000000..ce380ad --- /dev/null +++ b/test/helper/resources/path-prefix-path-item-server-to-path/after.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +paths: + /api/v1/users: + servers: + - url: https://example.com + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-path-item-server-to-path/before.yaml b/test/helper/resources/path-prefix-path-item-server-to-path/before.yaml new file mode 100644 index 0000000..9767c9d --- /dev/null +++ b/test/helper/resources/path-prefix-path-item-server-to-path/before.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +paths: + /users: + servers: + - url: https://example.com/api/v1 + get: + summary: Get users + responses: + '200': + description: OK + + + diff --git a/test/helper/resources/path-prefix-root-server-to-path/after.yaml b/test/helper/resources/path-prefix-root-server-to-path/after.yaml new file mode 100644 index 0000000..e57865f --- /dev/null +++ b/test/helper/resources/path-prefix-root-server-to-path/after.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com +paths: + /api/v1/users: + get: + summary: Get users + responses: + '200': + description: OK + diff --git a/test/helper/resources/path-prefix-root-server-to-path/before.yaml b/test/helper/resources/path-prefix-root-server-to-path/before.yaml new file mode 100644 index 0000000..474e94e --- /dev/null +++ b/test/helper/resources/path-prefix-root-server-to-path/before.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://example.com/api/v1 +paths: + /users: + get: + summary: Get users + responses: + '200': + description: OK + diff --git a/test/helper/utils.ts b/test/helper/utils.ts index 3f673a1..a73aeb8 100644 --- a/test/helper/utils.ts +++ b/test/helper/utils.ts @@ -1,5 +1,11 @@ import { buildSchema } from "graphql" import { buildFromSchema, GraphApiSchema } from '@netcracker/qubership-apihub-graphapi' +import { readFileSync } from 'fs' +import { load } from 'js-yaml' + +export function loadYamlSample(path: string) { + return load(readFileSync(`./test/helper/resources/${path}`).toString()) +} export function takeIf(value: object, condition: boolean): object { return { diff --git a/test/openapi.pathAndMethodMapping.test.ts b/test/openapi.pathAndMethodMapping.test.ts new file mode 100644 index 0000000..3f6a4d8 --- /dev/null +++ b/test/openapi.pathAndMethodMapping.test.ts @@ -0,0 +1,179 @@ +import { annotation, apiDiff, DiffAction } from '../src' +import { loadYamlSample, OpenapiBuilder } from './helper' +import { diffsMatcher } from './helper/matchers' + +describe('Path and method mapping', () => { + let openapiBuilder: OpenapiBuilder + + beforeEach(() => { + openapiBuilder = new OpenapiBuilder() + }) + + it('Move prefix from server to path', () => { + const before = openapiBuilder + .addServer('https://example1.com/api/v2') + .addPath({ + path: '/path1', + responses: { + '200': { + description: 'OK', + }, + }, + }) + .getSpec() + + openapiBuilder.reset() + + const after = openapiBuilder + .addServer('https://example1.com') + .addPath({ + path: '/api/v2/path1', responses: { + '200': { + description: 'not OK', + }, + }, + }) + .getSpec() + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/path1']], + afterDeclarationPaths: [['paths', '/api/v2/path1']], + action: DiffAction.rename, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/path1', 'get', 'responses', '200', 'description']], + afterDeclarationPaths: [['paths', '/api/v2/path1', 'get', 'responses', '200', 'description']], + action: DiffAction.replace, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['servers', 0, 'url']], + action: DiffAction.replace, + type: annotation, + }), + ])) + }) + + it('Remove mistyped slashes', () => { + const before = openapiBuilder + .addPath({ + path: '//path1/', + responses: { '200': { description: 'OK' } }, + }) + .getSpec() + + openapiBuilder.reset() + + const after = openapiBuilder + .addPath({ + path: '/path1', + responses: { '200': { description: 'OK' } }, + }) + .getSpec() + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '//path1/']], + afterDeclarationPaths: [['paths', '/path1']], + action: DiffAction.rename, + type: annotation, + }) + ])) + }) + + it('Should match operation when prefix moved from root servers to path', () => { + const before = loadYamlSample('path-prefix-root-server-to-path/before.yaml') + const after = loadYamlSample('path-prefix-root-server-to-path/after.yaml') + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users']], + afterDeclarationPaths: [['paths', '/api/v1/users']], + action: DiffAction.rename, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['servers', 0, 'url']], + action: DiffAction.replace, + type: annotation, + }), + ])) + }) + + it('Should match operation when prefix moved from path item object servers to path', () => { + const before = loadYamlSample('path-prefix-path-item-server-to-path/before.yaml') + const after = loadYamlSample('path-prefix-path-item-server-to-path/after.yaml') + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users']], + afterDeclarationPaths: [['paths', '/api/v1/users']], + action: DiffAction.rename, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users', 'servers', 0, 'url']], + afterDeclarationPaths: [['paths', '/api/v1/users', 'servers', 0, 'url']], + action: DiffAction.replace, + type: annotation, + }), + ])) + }) + + it('Should prioritize prefix specified in path item object servers to root servers', () => { + const before = loadYamlSample('path-prefix-path-item-priority/before.yaml') + const after = loadYamlSample('path-prefix-path-item-priority/after.yaml') + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + action: "rename", + afterDeclarationPaths: [["paths", "/api/v1/users"]], + beforeDeclarationPaths: [["paths", "/users"]], + type: "annotation", + }), + expect.objectContaining({ + action: "remove", + beforeDeclarationPaths: [["paths", "/users", "servers"]], + type: "annotation", + }), + expect.objectContaining({ + action: "remove", + beforeDeclarationPaths: [["servers"]], + type: "annotation", + }), + ])) + }) + + it.skip('Should match operation when prefix moved from operation object servers to path', () => { + const before = loadYamlSample('path-prefix-operation-server-to-path/before.yaml') + const after = loadYamlSample('path-prefix-operation-server-to-path/after.yaml') + + const { diffs } = apiDiff(before, after) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users']], + afterDeclarationPaths: [['paths', '/api/v1/users']], + action: DiffAction.rename, + type: annotation, + }), + expect.objectContaining({ + beforeDeclarationPaths: [['paths', '/users', 'get', 'servers']], + action: DiffAction.remove, + type: annotation, + }), + ])) + }) +}) diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 0000000..35de418 --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,61 @@ +/** + * 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 { extractOperationBasePath } from '../src/utils' + +describe('Unit test for extractOperationBasePath', () => { + test('Should handle Servers with parameters correctly', () => { + const servers = [{ + url: '{protocol}://{host}/api', + description: 'Remote server', + variables: { + protocol: { + description: 'Request protocol.', + enum: ['http', 'https'], + default: 'https', + }, + host: { + description: 'Name of the server, for remote development.', + enum: ['billing-ui-api.com'], + default: 'billing-ui-api.com', + }, + }, + }] + + expect(extractOperationBasePath(servers)).toEqual('/api') + }) + + test('Should handle Servers with absolute url correctly', () => { + expect(extractOperationBasePath([{ url: 'https://example.com/v1' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: 'https://example.com/v1/' }])).toEqual('/v1') + }) + + test('Should handle Servers with relative url correctly', () => { + expect(extractOperationBasePath([{ url: '/v1' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: 'v1' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: 'v1/' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: '/v1/' }])).toEqual('/v1') + expect(extractOperationBasePath([{ url: '/' }])).toEqual('') + }) + + // todo: should really handle this case in api-unifier, it returns incorrect object in this case, + // since url is required for server object + test('Should handle Servers with empty url correctly', () => { + // @ts-expect-error - Testing edge case with missing url property + expect(extractOperationBasePath([{ }])).toEqual('') + }) +}) +