From 8846a77d8d32799c9702428a10beeff623c75b42 Mon Sep 17 00:00:00 2001 From: Pavel Makeev Date: Mon, 8 Dec 2025 12:01:59 +0400 Subject: [PATCH 1/6] feat: POC - Reclassify diffs to risky in a merged document for no-bwc operation --- src/core/compare.ts | 32 +++- src/jsonSchema/jsonSchema.classify.ts | 4 +- src/openapi/openapi3.rules.ts | 11 +- src/types/compare.ts | 12 ++ src/types/rules.ts | 1 + test/backward-compatibility.test.ts | 153 ++++++++++++++++++ .../backward-compatibility/case2/after.json | 25 +++ .../backward-compatibility/case2/before.json | 25 +++ .../backward-compatibility/case3/after.json | 39 +++++ .../backward-compatibility/case3/before.json | 39 +++++ .../backward-compatibility/case4/after.json | 75 +++++++++ .../backward-compatibility/case4/before.json | 75 +++++++++ .../backward-compatibility/case5/after.json | 113 +++++++++++++ .../backward-compatibility/case5/before.json | 113 +++++++++++++ .../backward-compatibility/case6/after.json | 33 ++++ .../backward-compatibility/case6/before.json | 33 ++++ 16 files changed, 773 insertions(+), 10 deletions(-) create mode 100644 test/backward-compatibility.test.ts create mode 100644 test/helper/resources/backward-compatibility/case2/after.json create mode 100644 test/helper/resources/backward-compatibility/case2/before.json create mode 100644 test/helper/resources/backward-compatibility/case3/after.json create mode 100644 test/helper/resources/backward-compatibility/case3/before.json create mode 100644 test/helper/resources/backward-compatibility/case4/after.json create mode 100644 test/helper/resources/backward-compatibility/case4/before.json create mode 100644 test/helper/resources/backward-compatibility/case5/after.json create mode 100644 test/helper/resources/backward-compatibility/case5/before.json create mode 100644 test/helper/resources/backward-compatibility/case6/after.json create mode 100644 test/helper/resources/backward-compatibility/case6/before.json diff --git a/src/core/compare.ts b/src/core/compare.ts index 51cff70..8b6ef6b 100644 --- a/src/core/compare.ts +++ b/src/core/compare.ts @@ -16,6 +16,8 @@ import { deepEqual } from 'fast-equals' import { AdapterContext, AdapterResolver, + API_KIND, + ApiKind, CompareContext, CompareResult, CompareRule, @@ -75,6 +77,7 @@ export const createContext = (data: ContextInput, options: InternalCompareOption rules, compareScope, parentContext, + noApiBackwardCompatibility, } = data return { parentContext: parentContext, @@ -84,6 +87,7 @@ export const createContext = (data: ContextInput, options: InternalCompareOption mergeKey, rules, options, + noApiBackwardCompatibility, } } @@ -203,7 +207,7 @@ const adaptValues = (beforeJso: JsonNode, beforeKey: PropertyKey, afterJso: Json } const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): SyncCrawlHook => { - const { metaKey } = options + const { metaKey, isNoApiBackwardCompatibility } = options const diffs: Set = new Set() const addDiff: (diff: Diff) => void = (diff) => { const oldSize = diffs.size @@ -229,6 +233,7 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): diffUniquenessCache, createdMergedJso, compareScope, + noApiBackwardCompatibility, } = state if (typeof unsafeKey === 'symbol') { @@ -261,12 +266,28 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): mergeKey, rules, compareScope: newCompareScope ?? compareScope, + noApiBackwardCompatibility, }, options) const beforeDeclarativePathsId = buildPathsIdentifier(cleanUpRecursive(ctx.before).declarativePaths) const afterDeclarativePathsId = buildPathsIdentifier(cleanUpRecursive(ctx.after).declarativePaths) - - const reuseResult: ReusableMergeResult = mergedJsoCache.cacheEvaluationResultByFootprint<[typeof ctx.before.value, typeof ctx.after.value, typeof beforeDeclarativePathsId, typeof afterDeclarativePathsId, CompareScope], ReusableMergeResult>([ctx.before.value, ctx.after.value, beforeDeclarativePathsId, afterDeclarativePathsId, ctx.scope], ([beforeValue, afterValue]) => { + const apiKind: ApiKind = noApiBackwardCompatibility ? API_KIND.NO_BWC : API_KIND.BWC + + const reuseResult: ReusableMergeResult = mergedJsoCache.cacheEvaluationResultByFootprint<[ + typeof ctx.before.value, + typeof ctx.after.value, + typeof beforeDeclarativePathsId, + typeof afterDeclarativePathsId, + CompareScope, + ApiKind + ], ReusableMergeResult>([ + ctx.before.value, + ctx.after.value, + beforeDeclarativePathsId, + afterDeclarativePathsId, + ctx.scope, + apiKind, + ], ([beforeValue, afterValue]) => { if (!ignoreKeyDifference && beforeKey !== afterKey) { const diffEntry = createDiffEntry(ctx, diffFactory.renamed(ctx)) addDiff(diffEntry.diff) @@ -363,6 +384,7 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): afterJso: afterValueAdapted as JsonNode/*safe cause it only happens for object*/, mergedJso: mergedValue, compareScope: newCompareScope ?? compareScope, + noApiBackwardCompatibility: isNoApiBackwardCompatibility?.(crawlContext.path), } return { value: reuseResult.nextValue, state: childState, exitHook: reuseResult.exitHook } } else { @@ -439,7 +461,7 @@ function addNormalizedValuesToDenormalizedDiff( denormalizedDiffs: Diff[], rawDiffs: Diff[], beforeValueNormalizedProperty?: symbol, - afterValueNormalizedProperty?: symbol + afterValueNormalizedProperty?: symbol, ) { for (let i = 0; i < denormalizedDiffs.length && i < rawDiffs.length; i++) { const denormalizedDiff = denormalizedDiffs[i] @@ -507,7 +529,7 @@ export const compare = (before: unknown, after: unknown, options: InternalCompar denormalizedDiffs, rawDiffs, options.beforeValueNormalizedProperty, - options.afterValueNormalizedProperty + options.afterValueNormalizedProperty, ) return { diffs: denormalizedDiffs, diff --git a/src/jsonSchema/jsonSchema.classify.ts b/src/jsonSchema/jsonSchema.classify.ts index c759ced..bfd6e54 100644 --- a/src/jsonSchema/jsonSchema.classify.ts +++ b/src/jsonSchema/jsonSchema.classify.ts @@ -23,10 +23,10 @@ import type { ClassifyRule } from '../types' export const typeClassifier: ClassifyRule = [ breaking,//not tested breaking,//not tested - ({ before, after }) => nonBreakingIf(isTypeAssignable(before.value, after.value, false)), + ({ before, after, noApiBackwardCompatibility }) => (noApiBackwardCompatibility ? risky : nonBreakingIf(isTypeAssignable(before.value, after.value, false))), breaking,//not tested breaking,//not tested - ({ before, after }) => nonBreakingIf(isTypeAssignable(before.value, after.value, true)), + ({ before, after, noApiBackwardCompatibility}) => (noApiBackwardCompatibility ? risky : nonBreakingIf(isTypeAssignable(before.value, after.value, true))), ] export const maxClassifier: ClassifyRule = [ diff --git a/src/openapi/openapi3.rules.ts b/src/openapi/openapi3.rules.ts index 1903667..5fb6f04 100644 --- a/src/openapi/openapi3.rules.ts +++ b/src/openapi/openapi3.rules.ts @@ -6,6 +6,7 @@ import { allUnclassified, breaking, breakingIfAfterTrue, + deepEqualsUniqueItemsArrayMappingResolver, diffDescription, GREP_TEMPLATE_PARAM_ENCODING_NAME, GREP_TEMPLATE_PARAM_EXAMPLE_NAME, @@ -14,6 +15,7 @@ import { GREP_TEMPLATE_PARAM_PARAMETER_NAME, GREP_TEMPLATE_PARAM_RESPONSE_NAME, nonBreaking, + risky, TEMPLATE_PARAM_ACTION, TEMPLATE_PARAM_COMPONENT_PATH, TEMPLATE_PARAM_EXAMPLE_PATH, @@ -27,7 +29,6 @@ import { TEMPLATE_PARAM_RESPONSE_PATH, TEMPLATE_PARAM_SCOPE, unclassified, - deepEqualsUniqueItemsArrayMappingResolver, } from '../core' import { COMPARE_MODE_OPERATION, @@ -62,7 +63,7 @@ import { import { isResponseSchema } from './openapi3.utils' import { apihubCaseInsensitiveKeyMappingResolver } from './mapping' import { nonBreakingIf } from '../utils' -import { COMPARE_SCOPE_COMPONENTS, COMPARE_SCOPE_RESPONSE, COMPARE_SCOPE_REQUEST } from './openapi3.const' +import { COMPARE_SCOPE_COMPONENTS, COMPARE_SCOPE_REQUEST, COMPARE_SCOPE_RESPONSE } from './openapi3.const' import { parameterParamsCalculator } from './openapi3.description.parameter' import { requestParamsCalculator } from './openapi3.description.request' import { responseParamsCalculator } from './openapi3.description.response' @@ -342,7 +343,11 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { } const responseRules: CompareRules = { - $: [nonBreaking, breaking, (ctx) => nonBreakingIf(ctx.before.key.toString().toLocaleLowerCase() === ctx.after.key.toString().toLocaleLowerCase())], + $: [ + nonBreaking, + (ctx) => (ctx.noApiBackwardCompatibility ? risky : breaking), + (ctx) => (ctx.noApiBackwardCompatibility ? risky : nonBreakingIf(ctx.before.key.toString().toLocaleLowerCase() === ctx.after.key.toString().toLocaleLowerCase())) + ], description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] response '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}'`), descriptionParamCalculator: responseParamsCalculator, '/content': contentRules, diff --git a/src/types/compare.ts b/src/types/compare.ts index c099a5d..abce913 100644 --- a/src/types/compare.ts +++ b/src/types/compare.ts @@ -82,6 +82,7 @@ export const COMPARE_MODE_DEFAULT = 'default' export const COMPARE_MODE_OPERATION = 'operation' export type CompareMode = typeof COMPARE_MODE_DEFAULT | typeof COMPARE_MODE_OPERATION +export type NoBackwardCompatibility = (path: PropertyKey[]) => boolean export interface CompareOptions extends Omit { mode?: CompareMode @@ -92,6 +93,7 @@ export interface CompareOptions extends Omit { onCreateDiffError?: (message: string, diff: Diff, ctx: CompareContext) => void beforeValueNormalizedProperty?: symbol afterValueNormalizedProperty?: symbol + isNoApiBackwardCompatibility?: NoBackwardCompatibility } export type DiffCallback = (diff: Diff/*, ctx: CompareContext*/) => void @@ -118,6 +120,15 @@ export type CompareEngine = (before: unknown, after: unknown, options: StrictCom export type NodeRoot = { [JSO_ROOT]: any } export type KeyMapping = Record +export const API_KIND = { + BWC: 'bwc', + NO_BWC: 'no-bwc', + EXPERIMENTAL: 'experimental', +} as const + +export type KeyOfConstType = T[keyof T] +export type ApiKind = KeyOfConstType + export interface MergeState { parentContext: CompareContext | undefined keyMap: KeyMapping // parent keys mappings @@ -133,6 +144,7 @@ export interface MergeState { diffUniquenessCache: EvaluationCacheService, createdMergedJso: Set, compareScope: CompareScope + noApiBackwardCompatibility?: boolean } export type JsonNode = Key extends (string | symbol) ? Record : Record | Array diff --git a/src/types/rules.ts b/src/types/rules.ts index 5221c67..596981a 100644 --- a/src/types/rules.ts +++ b/src/types/rules.ts @@ -44,6 +44,7 @@ export interface CompareContext { mergeKey: PropertyKey rules: CompareRules options: InternalCompareOptions + noApiBackwardCompatibility?: boolean } export interface AdapterContext { diff --git a/test/backward-compatibility.test.ts b/test/backward-compatibility.test.ts new file mode 100644 index 0000000..9cb4b91 --- /dev/null +++ b/test/backward-compatibility.test.ts @@ -0,0 +1,153 @@ +import { apiDiff, breaking, DiffAction, risky } from '../src' + +import case2Before from './helper/resources/backward-compatibility/case2/before.json' +import case2After from './helper/resources/backward-compatibility/case2/after.json' + +import case3Before from './helper/resources/backward-compatibility/case3/before.json' +import case3After from './helper/resources/backward-compatibility/case3/after.json' + +import case4Before from './helper/resources/backward-compatibility/case4/before.json' +import case4After from './helper/resources/backward-compatibility/case4/after.json' + +import case5Before from './helper/resources/backward-compatibility/case5/before.json' +import case5After from './helper/resources/backward-compatibility/case5/after.json' + +import case6Before from './helper/resources/backward-compatibility/case6/before.json' +import case6After from './helper/resources/backward-compatibility/case6/after.json' + +import { diffsMatcher } from './helper/matchers' + +type PATH_ENTRY = [string, string, string] + +const GET_PATH1: PATH_ENTRY = ['paths', '/path1', 'get'] + +function createNoApiBackwardCompatibilityFn(data: PATH_ENTRY[]): (path: PropertyKey[]) => boolean { + return (path) => data.some(entry => + entry.every((el, i) => path[i] === el), + ) +} + +describe('Backward compatibility tests', () => { + it('should diff from get has risky type with NoApiBackwardCompatibility', async () => { + const { diffs } = apiDiff(case2Before, case2After, { isNoApiBackwardCompatibility: () => true }) + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + ])) + }) + + it('3', async () => { + const { diffs } = apiDiff( + case3Before, + case3After, + { + isNoApiBackwardCompatibility: createNoApiBackwardCompatibilityFn([GET_PATH1]), + }) + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: breaking, + }), + ])) + }) + + it('4', async () => { + const { diffs } = apiDiff( + case4Before, + case4After, + { + isNoApiBackwardCompatibility: createNoApiBackwardCompatibilityFn([GET_PATH1]), + }) + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: breaking, + }), + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + expect.objectContaining({ + scope: 'components', + action: DiffAction.replace, + type: breaking, + }), + ])) + }) + + it('5', async () => { + const { diffs } = apiDiff( + case5Before, + case5After, + { + isNoApiBackwardCompatibility: createNoApiBackwardCompatibilityFn([GET_PATH1]), + }) + expect(diffs).toEqual(diffsMatcher([ + // post + expect.objectContaining({ + scope: 'request', + action: DiffAction.replace, + type: breaking, + }), + // post + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: breaking, + }), + // get + expect.objectContaining({ + scope: 'request', + action: DiffAction.replace, + type: risky, + }), + // get + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + expect.objectContaining({ + scope: 'components', + action: DiffAction.replace, + type: breaking, + }), + ])) + }) + + it('6', async () => { + const { diffs } = apiDiff( + case6Before, + case6After, + { + isNoApiBackwardCompatibility: createNoApiBackwardCompatibilityFn([GET_PATH1]), + }) + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeValue: 'number', + afterValue: 'string', + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + expect.objectContaining({ + beforeValue: 'string', + afterValue: 'number', + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + ])) + }) +}) diff --git a/test/helper/resources/backward-compatibility/case2/after.json b/test/helper/resources/backward-compatibility/case2/after.json new file mode 100644 index 0000000..fafe297 --- /dev/null +++ b/test/helper/resources/backward-compatibility/case2/after.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "test", + "version": "0.1.0" + }, + "paths": { + "/path1": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + } +} diff --git a/test/helper/resources/backward-compatibility/case2/before.json b/test/helper/resources/backward-compatibility/case2/before.json new file mode 100644 index 0000000..6adac56 --- /dev/null +++ b/test/helper/resources/backward-compatibility/case2/before.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "test", + "version": "0.1.0" + }, + "paths": { + "/path1": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "number" + } + } + } + } + } + } + } + } +} diff --git a/test/helper/resources/backward-compatibility/case3/after.json b/test/helper/resources/backward-compatibility/case3/after.json new file mode 100644 index 0000000..d775ffc --- /dev/null +++ b/test/helper/resources/backward-compatibility/case3/after.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "test", + "version": "0.1.0" + }, + "paths": { + "/path1": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + } +} diff --git a/test/helper/resources/backward-compatibility/case3/before.json b/test/helper/resources/backward-compatibility/case3/before.json new file mode 100644 index 0000000..5e99dbf --- /dev/null +++ b/test/helper/resources/backward-compatibility/case3/before.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "test", + "version": "0.1.0" + }, + "paths": { + "/path1": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "number" + } + } + } + } + } + }, + "post": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "number" + } + } + } + } + } + } + } + } +} diff --git a/test/helper/resources/backward-compatibility/case4/after.json b/test/helper/resources/backward-compatibility/case4/after.json new file mode 100644 index 0000000..f1086dc --- /dev/null +++ b/test/helper/resources/backward-compatibility/case4/after.json @@ -0,0 +1,75 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Sample API", + "description": "API specification example", + "version": "1.0.0" + }, + "paths": { + "/path1": { + "post": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + } + } + }, + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "componentsValue": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "currency": { + "type": "string" + } + } + } + } + } +} diff --git a/test/helper/resources/backward-compatibility/case4/before.json b/test/helper/resources/backward-compatibility/case4/before.json new file mode 100644 index 0000000..77cb225 --- /dev/null +++ b/test/helper/resources/backward-compatibility/case4/before.json @@ -0,0 +1,75 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Sample API", + "description": "API specification example", + "version": "1.0.0" + }, + "paths": { + "/path1": { + "post": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + } + } + }, + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "componentsValue": { + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "currency": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/test/helper/resources/backward-compatibility/case5/after.json b/test/helper/resources/backward-compatibility/case5/after.json new file mode 100644 index 0000000..be33f43 --- /dev/null +++ b/test/helper/resources/backward-compatibility/case5/after.json @@ -0,0 +1,113 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Sample API", + "description": "API specification example", + "version": "1.0.0" + }, + "paths": { + "/path1": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + } + } + }, + "get": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "componentsValue": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "currency": { + "type": "string" + } + } + } + } + } +} diff --git a/test/helper/resources/backward-compatibility/case5/before.json b/test/helper/resources/backward-compatibility/case5/before.json new file mode 100644 index 0000000..c395db8 --- /dev/null +++ b/test/helper/resources/backward-compatibility/case5/before.json @@ -0,0 +1,113 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Sample API", + "description": "API specification example", + "version": "1.0.0" + }, + "paths": { + "/path1": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + } + } + }, + "get": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "object", + "properties": { + "prop1": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "componentsValue": { + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "currency": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/test/helper/resources/backward-compatibility/case6/after.json b/test/helper/resources/backward-compatibility/case6/after.json new file mode 100644 index 0000000..45b4723 --- /dev/null +++ b/test/helper/resources/backward-compatibility/case6/after.json @@ -0,0 +1,33 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "test", + "version": "0.1.0" + }, + "paths": { + "/path1": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "field1": { + "type": "string" + }, + "field2": { + "type": "number" + } + } + } + } + } + } + } + } + } + } +} diff --git a/test/helper/resources/backward-compatibility/case6/before.json b/test/helper/resources/backward-compatibility/case6/before.json new file mode 100644 index 0000000..5a34bc1 --- /dev/null +++ b/test/helper/resources/backward-compatibility/case6/before.json @@ -0,0 +1,33 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "test", + "version": "0.1.0" + }, + "paths": { + "/path1": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "field1": { + "type": "number" + }, + "field2": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +} From 81c18aff5dc04925b024f67ba042eaf8ec252f91 Mon Sep 17 00:00:00 2001 From: Pavel Makeev Date: Tue, 9 Dec 2025 09:05:22 +0400 Subject: [PATCH 2/6] feat: Added classifyRiskyRuleTransformer --- src/core/rules.ts | 19 +++++++++++++++++-- src/jsonSchema/jsonSchema.classify.ts | 4 ++-- src/openapi/openapi3.rules.ts | 16 ++++++++-------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/core/rules.ts b/src/core/rules.ts index 9b5bea0..5cad95a 100644 --- a/src/core/rules.ts +++ b/src/core/rules.ts @@ -11,7 +11,7 @@ import { RuleDiffType, } from '../types' import { isFunc, isObject, isString } from '../utils' -import { breaking, DiffAction, nonBreaking } from './constants' +import { breaking, DiffAction, nonBreaking, risky } from './constants' export const transformCompareRules = (rules: CompareRules, transformer: CompareRulesTransformer): CompareRules => { return syncClone(rules, ({ value, key, state, path }) => { @@ -80,7 +80,6 @@ export const transformClassifyRule = ([add, remove, replace, reverseAdd, reverse transformedRule(remove, DiffAction.remove), transformedRule(replace, DiffAction.replace), ] - } export const breakingIf = (v: boolean): DiffType => (v ? breaking : nonBreaking) @@ -91,3 +90,19 @@ export const booleanClassifier: ClassifyRule = [ nonBreaking, breakingIfAfterTrue, ] + +export const classifyRiskyRuleTransformer: CompareRulesTransformer = (value) => { + if ('$' in value && Array.isArray(value.$)) { + return { + ...value, + $: transformClassifyRule( + value.$, + (type, { noApiBackwardCompatibility }, action) => ( + type === breaking && noApiBackwardCompatibility ? risky : type + ), + ), + } + } + + return value +} diff --git a/src/jsonSchema/jsonSchema.classify.ts b/src/jsonSchema/jsonSchema.classify.ts index bfd6e54..0abdfce 100644 --- a/src/jsonSchema/jsonSchema.classify.ts +++ b/src/jsonSchema/jsonSchema.classify.ts @@ -23,10 +23,10 @@ import type { ClassifyRule } from '../types' export const typeClassifier: ClassifyRule = [ breaking,//not tested breaking,//not tested - ({ before, after, noApiBackwardCompatibility }) => (noApiBackwardCompatibility ? risky : nonBreakingIf(isTypeAssignable(before.value, after.value, false))), + ({ before, after }) => (nonBreakingIf(isTypeAssignable(before.value, after.value, false))), breaking,//not tested breaking,//not tested - ({ before, after, noApiBackwardCompatibility}) => (noApiBackwardCompatibility ? risky : nonBreakingIf(isTypeAssignable(before.value, after.value, true))), + ({ before, after }) => (nonBreakingIf(isTypeAssignable(before.value, after.value, true))), ] export const maxClassifier: ClassifyRule = [ diff --git a/src/openapi/openapi3.rules.ts b/src/openapi/openapi3.rules.ts index 5fb6f04..029925c 100644 --- a/src/openapi/openapi3.rules.ts +++ b/src/openapi/openapi3.rules.ts @@ -6,6 +6,7 @@ import { allUnclassified, breaking, breakingIfAfterTrue, + classifyRiskyRuleTransformer, deepEqualsUniqueItemsArrayMappingResolver, diffDescription, GREP_TEMPLATE_PARAM_ENCODING_NAME, @@ -15,7 +16,6 @@ import { GREP_TEMPLATE_PARAM_PARAMETER_NAME, GREP_TEMPLATE_PARAM_RESPONSE_NAME, nonBreaking, - risky, TEMPLATE_PARAM_ACTION, TEMPLATE_PARAM_COMPONENT_PATH, TEMPLATE_PARAM_EXAMPLE_PATH, @@ -28,6 +28,7 @@ import { TEMPLATE_PARAM_REQUEST_PATH, TEMPLATE_PARAM_RESPONSE_PATH, TEMPLATE_PARAM_SCOPE, + transformCompareRules, unclassified, } from '../core' import { @@ -90,8 +91,11 @@ const operationAnnotationRule: CompareRules = { $: allAnnotation } ***/ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { - const requestSchemaRules = openApiSchemaRules(options) - const responseSchemaRules = openApiSchemaRules({ ...options, response: true }) + const requestSchemaRules = transformCompareRules(openApiSchemaRules(options), classifyRiskyRuleTransformer) + const responseSchemaRules = transformCompareRules(openApiSchemaRules({ + ...options, + response: true, + }), classifyRiskyRuleTransformer) const serversRules: CompareRules = { $: allAnnotation, @@ -343,11 +347,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { } const responseRules: CompareRules = { - $: [ - nonBreaking, - (ctx) => (ctx.noApiBackwardCompatibility ? risky : breaking), - (ctx) => (ctx.noApiBackwardCompatibility ? risky : nonBreakingIf(ctx.before.key.toString().toLocaleLowerCase() === ctx.after.key.toString().toLocaleLowerCase())) - ], + $: [nonBreaking, breaking, (ctx) => nonBreakingIf(ctx.before.key.toString().toLocaleLowerCase() === ctx.after.key.toString().toLocaleLowerCase())], description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] response '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}'`), descriptionParamCalculator: responseParamsCalculator, '/content': contentRules, From cef00ab35f4ce6656a8681321e4b462599b8f94a Mon Sep 17 00:00:00 2001 From: Pavel Makeev Date: Wed, 10 Dec 2025 13:48:17 +0400 Subject: [PATCH 3/6] feat: fixed the function on BwcScopeFunction --- src/core/compare.ts | 18 +++++++++++------- src/core/diff.ts | 24 +++++++++++++++++++++--- src/core/rules.ts | 18 +----------------- src/index.ts | 4 ++++ src/openapi/openapi3.rules.ts | 9 ++------- src/types/compare.ts | 25 ++++++++++++++----------- src/types/rules.ts | 4 ++-- test/backward-compatibility.test.ts | 25 +++++++++++++++---------- 8 files changed, 70 insertions(+), 57 deletions(-) diff --git a/src/core/compare.ts b/src/core/compare.ts index 8b6ef6b..333a6cd 100644 --- a/src/core/compare.ts +++ b/src/core/compare.ts @@ -77,7 +77,7 @@ export const createContext = (data: ContextInput, options: InternalCompareOption rules, compareScope, parentContext, - noApiBackwardCompatibility, + backwardCompatibility, } = data return { parentContext: parentContext, @@ -87,7 +87,7 @@ export const createContext = (data: ContextInput, options: InternalCompareOption mergeKey, rules, options, - noApiBackwardCompatibility, + backwardCompatibility, } } @@ -137,6 +137,7 @@ export const createChildContext = ( ) ?? {}, options, scope: scope, + backwardCompatibility: ctx.backwardCompatibility, } } @@ -207,7 +208,7 @@ const adaptValues = (beforeJso: JsonNode, beforeKey: PropertyKey, afterJso: Json } const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): SyncCrawlHook => { - const { metaKey, isNoApiBackwardCompatibility } = options + const { metaKey, bwcScopeFunction } = options const diffs: Set = new Set() const addDiff: (diff: Diff) => void = (diff) => { const oldSize = diffs.size @@ -233,7 +234,7 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): diffUniquenessCache, createdMergedJso, compareScope, - noApiBackwardCompatibility, + backwardCompatibility: currentBackwardCompatibility, } = state if (typeof unsafeKey === 'symbol') { @@ -257,6 +258,8 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): afterValueAdapted, ] = adaptValues(beforeJso, beforeKey, afterJso, afterKey, adapter, options) + const backwardCompatibility = bwcScopeFunction?.(crawlContext.path) ?? currentBackwardCompatibility + const ctx = createContext({ ...state, beforeValue: beforeValueAdapted, @@ -266,12 +269,12 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): mergeKey, rules, compareScope: newCompareScope ?? compareScope, - noApiBackwardCompatibility, + backwardCompatibility, }, options) const beforeDeclarativePathsId = buildPathsIdentifier(cleanUpRecursive(ctx.before).declarativePaths) const afterDeclarativePathsId = buildPathsIdentifier(cleanUpRecursive(ctx.after).declarativePaths) - const apiKind: ApiKind = noApiBackwardCompatibility ? API_KIND.NO_BWC : API_KIND.BWC + const apiKind: ApiKind = backwardCompatibility const reuseResult: ReusableMergeResult = mergedJsoCache.cacheEvaluationResultByFootprint<[ typeof ctx.before.value, @@ -384,7 +387,7 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): afterJso: afterValueAdapted as JsonNode/*safe cause it only happens for object*/, mergedJso: mergedValue, compareScope: newCompareScope ?? compareScope, - noApiBackwardCompatibility: isNoApiBackwardCompatibility?.(crawlContext.path), + backwardCompatibility, } return { value: reuseResult.nextValue, state: childState, exitHook: reuseResult.exitHook } } else { @@ -570,6 +573,7 @@ const compareInternal = (before: unknown, after: unknown, onDiff: DiffCallback, diffUniquenessCache: options.diffUniquenessCache, createdMergedJso: options.createdMergedJso, compareScope: options.compareScope, + backwardCompatibility: API_KIND.BACKWARD_COMPATIBLE, } syncCrawl(before, [hook], { state: rootState, rules: options.rules }) return root.merged[JSO_ROOT] diff --git a/src/core/diff.ts b/src/core/diff.ts index cc76e47..14e0825 100644 --- a/src/core/diff.ts +++ b/src/core/diff.ts @@ -1,7 +1,20 @@ import { JsonPath } from '@netcracker/qubership-apihub-json-crawl' -import { CompareContext, Diff, DiffAdd, DiffRemove, DiffReplace, DiffRename, DiffEntry, DiffFactory, DiffMetaRecord, NodeContext } from '../types' -import { allUnclassified, DiffAction, unclassified } from './constants' +import { + API_KIND, + CompareContext, + Diff, + DiffAdd, + DiffEntry, + DiffFactory, + DiffMetaRecord, + DiffRemove, + DiffRename, + DiffReplace, + DiffType, + NodeContext, +} from '../types' +import { allUnclassified, breaking, DiffAction, risky, unclassified } from './constants' import { getKeyValue, isFunc } from '../utils' import { calculateDefaultDiffDescription } from './description' @@ -18,7 +31,8 @@ export const createDiff = (diff: Omit, ctx: CompareCo const changeType = classifier[index] try { - mutableDiffCopy.type = isFunc(changeType) ? changeType(ctx) : changeType + const type = isFunc(changeType) ? changeType(ctx) : changeType + mutableDiffCopy.type = reclassifyTypeToRisky(type, ctx) } catch (error) { ctx.options.onCreateDiffError?.(`Unable to find diff type. ${error instanceof Error ? error.message : ''}`, mutableDiffCopy, ctx) } @@ -31,6 +45,10 @@ export const createDiff = (diff: Omit, ctx: CompareCo return mutableDiffCopy } +export const reclassifyTypeToRisky = (type: DiffType, ctx: CompareContext): DiffType => { + return type === breaking && ctx.backwardCompatibility === API_KIND.NOT_BACKWARD_COMPATIBLE ? risky : type +} + export function createDiffEntry(ctx: CompareContext, diff: Diff): DiffEntry { return ({ propertyKey: ctx.mergeKey, diff --git a/src/core/rules.ts b/src/core/rules.ts index 5cad95a..8cd5928 100644 --- a/src/core/rules.ts +++ b/src/core/rules.ts @@ -11,7 +11,7 @@ import { RuleDiffType, } from '../types' import { isFunc, isObject, isString } from '../utils' -import { breaking, DiffAction, nonBreaking, risky } from './constants' +import { breaking, DiffAction, nonBreaking } from './constants' export const transformCompareRules = (rules: CompareRules, transformer: CompareRulesTransformer): CompareRules => { return syncClone(rules, ({ value, key, state, path }) => { @@ -90,19 +90,3 @@ export const booleanClassifier: ClassifyRule = [ nonBreaking, breakingIfAfterTrue, ] - -export const classifyRiskyRuleTransformer: CompareRulesTransformer = (value) => { - if ('$' in value && Array.isArray(value.$)) { - return { - ...value, - $: transformClassifyRule( - value.$, - (type, { noApiBackwardCompatibility }, action) => ( - type === breaking && noApiBackwardCompatibility ? risky : type - ), - ), - } - } - - return value -} diff --git a/src/index.ts b/src/index.ts index 11df071..06ea092 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,9 @@ export { apiDiff } from './api' export type { CompareResult, CompareOptions, + ApiKind, + BwcScopeFunction, + BackwardCompatibilityState, DiffType, ActionType, Diff, @@ -26,6 +29,7 @@ export type { DiffRename, DiffMetaRecord, } from './types' +export { API_KIND } from './types' export { isDiffAdd, diff --git a/src/openapi/openapi3.rules.ts b/src/openapi/openapi3.rules.ts index 029925c..5c8ac27 100644 --- a/src/openapi/openapi3.rules.ts +++ b/src/openapi/openapi3.rules.ts @@ -6,7 +6,6 @@ import { allUnclassified, breaking, breakingIfAfterTrue, - classifyRiskyRuleTransformer, deepEqualsUniqueItemsArrayMappingResolver, diffDescription, GREP_TEMPLATE_PARAM_ENCODING_NAME, @@ -28,7 +27,6 @@ import { TEMPLATE_PARAM_REQUEST_PATH, TEMPLATE_PARAM_RESPONSE_PATH, TEMPLATE_PARAM_SCOPE, - transformCompareRules, unclassified, } from '../core' import { @@ -91,11 +89,8 @@ const operationAnnotationRule: CompareRules = { $: allAnnotation } ***/ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { - const requestSchemaRules = transformCompareRules(openApiSchemaRules(options), classifyRiskyRuleTransformer) - const responseSchemaRules = transformCompareRules(openApiSchemaRules({ - ...options, - response: true, - }), classifyRiskyRuleTransformer) + const requestSchemaRules = openApiSchemaRules(options) + const responseSchemaRules = openApiSchemaRules({ ...options, response: true }) const serversRules: CompareRules = { $: allAnnotation, diff --git a/src/types/compare.ts b/src/types/compare.ts index abce913..85b6f06 100644 --- a/src/types/compare.ts +++ b/src/types/compare.ts @@ -82,7 +82,17 @@ export const COMPARE_MODE_DEFAULT = 'default' export const COMPARE_MODE_OPERATION = 'operation' export type CompareMode = typeof COMPARE_MODE_DEFAULT | typeof COMPARE_MODE_OPERATION -export type NoBackwardCompatibility = (path: PropertyKey[]) => boolean + +export enum API_KIND { + BACKWARD_COMPATIBLE = 'BACKWARD_COMPATIBLE', + NOT_BACKWARD_COMPATIBLE = 'NOT_BACKWARD_COMPATIBLE' +} + +export type BackwardCompatibilityState = + | API_KIND.BACKWARD_COMPATIBLE + | API_KIND.NOT_BACKWARD_COMPATIBLE + +export type BwcScopeFunction = (path?: PropertyKey[]) => BackwardCompatibilityState | undefined export interface CompareOptions extends Omit { mode?: CompareMode @@ -93,7 +103,7 @@ export interface CompareOptions extends Omit { onCreateDiffError?: (message: string, diff: Diff, ctx: CompareContext) => void beforeValueNormalizedProperty?: symbol afterValueNormalizedProperty?: symbol - isNoApiBackwardCompatibility?: NoBackwardCompatibility + bwcScopeFunction?: BwcScopeFunction } export type DiffCallback = (diff: Diff/*, ctx: CompareContext*/) => void @@ -120,14 +130,7 @@ export type CompareEngine = (before: unknown, after: unknown, options: StrictCom export type NodeRoot = { [JSO_ROOT]: any } export type KeyMapping = Record -export const API_KIND = { - BWC: 'bwc', - NO_BWC: 'no-bwc', - EXPERIMENTAL: 'experimental', -} as const - -export type KeyOfConstType = T[keyof T] -export type ApiKind = KeyOfConstType +export type ApiKind = API_KIND export interface MergeState { parentContext: CompareContext | undefined @@ -144,7 +147,7 @@ export interface MergeState { diffUniquenessCache: EvaluationCacheService, createdMergedJso: Set, compareScope: CompareScope - noApiBackwardCompatibility?: boolean + backwardCompatibility: BackwardCompatibilityState } export type JsonNode = Key extends (string | symbol) ? Record : Record | Array diff --git a/src/types/rules.ts b/src/types/rules.ts index 596981a..9f19ff6 100644 --- a/src/types/rules.ts +++ b/src/types/rules.ts @@ -1,7 +1,7 @@ import { CrawlRules, JsonPath } from '@netcracker/qubership-apihub-json-crawl' import type { CompareResult, Diff, DiffType } from './compare' -import { CompareScope, InternalCompareOptions } from './compare' +import { BackwardCompatibilityState, CompareScope, InternalCompareOptions } from './compare' import { DiffAction } from '../core' import { OriginLeafs } from '@netcracker/qubership-apihub-api-unifier' @@ -44,7 +44,7 @@ export interface CompareContext { mergeKey: PropertyKey rules: CompareRules options: InternalCompareOptions - noApiBackwardCompatibility?: boolean + backwardCompatibility: BackwardCompatibilityState } export interface AdapterContext { diff --git a/test/backward-compatibility.test.ts b/test/backward-compatibility.test.ts index 9cb4b91..7e6960f 100644 --- a/test/backward-compatibility.test.ts +++ b/test/backward-compatibility.test.ts @@ -1,4 +1,4 @@ -import { apiDiff, breaking, DiffAction, risky } from '../src' +import { API_KIND, apiDiff, breaking, BwcScopeFunction, DiffAction, risky } from '../src' import case2Before from './helper/resources/backward-compatibility/case2/before.json' import case2After from './helper/resources/backward-compatibility/case2/after.json' @@ -21,15 +21,20 @@ type PATH_ENTRY = [string, string, string] const GET_PATH1: PATH_ENTRY = ['paths', '/path1', 'get'] -function createNoApiBackwardCompatibilityFn(data: PATH_ENTRY[]): (path: PropertyKey[]) => boolean { - return (path) => data.some(entry => - entry.every((el, i) => path[i] === el), - ) +function createBwcScopeFunction(data: PATH_ENTRY[]): BwcScopeFunction { + return (path?: PropertyKey[]) => { + if (path?.length !== 3) { + return undefined + } + return data.some(entry => + entry.every((el, i) => path?.[i] === el), + ) ? API_KIND.NOT_BACKWARD_COMPATIBLE : undefined + } } describe('Backward compatibility tests', () => { it('should diff from get has risky type with NoApiBackwardCompatibility', async () => { - const { diffs } = apiDiff(case2Before, case2After, { isNoApiBackwardCompatibility: () => true }) + const { diffs } = apiDiff(case2Before, case2After, { bwcScopeFunction: () => API_KIND.NOT_BACKWARD_COMPATIBLE }) expect(diffs).toEqual(diffsMatcher([ expect.objectContaining({ scope: 'response', @@ -44,7 +49,7 @@ describe('Backward compatibility tests', () => { case3Before, case3After, { - isNoApiBackwardCompatibility: createNoApiBackwardCompatibilityFn([GET_PATH1]), + bwcScopeFunction: createBwcScopeFunction([GET_PATH1]), }) expect(diffs).toEqual(diffsMatcher([ expect.objectContaining({ @@ -65,7 +70,7 @@ describe('Backward compatibility tests', () => { case4Before, case4After, { - isNoApiBackwardCompatibility: createNoApiBackwardCompatibilityFn([GET_PATH1]), + bwcScopeFunction: createBwcScopeFunction([GET_PATH1]), }) expect(diffs).toEqual(diffsMatcher([ expect.objectContaining({ @@ -91,7 +96,7 @@ describe('Backward compatibility tests', () => { case5Before, case5After, { - isNoApiBackwardCompatibility: createNoApiBackwardCompatibilityFn([GET_PATH1]), + bwcScopeFunction: createBwcScopeFunction([GET_PATH1]), }) expect(diffs).toEqual(diffsMatcher([ // post @@ -131,7 +136,7 @@ describe('Backward compatibility tests', () => { case6Before, case6After, { - isNoApiBackwardCompatibility: createNoApiBackwardCompatibilityFn([GET_PATH1]), + bwcScopeFunction: createBwcScopeFunction([GET_PATH1]), }) expect(diffs).toEqual(diffsMatcher([ expect.objectContaining({ From f8d22f182a2acc0ba02076e867c709d8d74ea5ff Mon Sep 17 00:00:00 2001 From: Pavel Makeev Date: Fri, 12 Dec 2025 08:51:57 +0400 Subject: [PATCH 4/6] feat: Updated bwcScopeFunction --- src/core/compare.ts | 48 ++++++++++++++++++++++++++++++++++++-------- src/core/diff.ts | 4 ++-- src/index.ts | 4 ++-- src/types/compare.ts | 14 ++++++------- src/types/rules.ts | 4 ++-- 5 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/core/compare.ts b/src/core/compare.ts index 333a6cd..a781d73 100644 --- a/src/core/compare.ts +++ b/src/core/compare.ts @@ -16,8 +16,8 @@ import { deepEqual } from 'fast-equals' import { AdapterContext, AdapterResolver, - API_KIND, ApiKind, + BwcState, CompareContext, CompareResult, CompareRule, @@ -32,6 +32,7 @@ import { JsonNode, MergeState, NodeContext, + ApiCompatibilityKind, ValueTransformer, } from '../types' import { getObjectValue, isArray, isDiffAdd, isDiffRemove, isDiffReplace, isNumber, isObject, typeOf } from '../utils' @@ -96,6 +97,7 @@ export const createChildContext = ( mergedKey: PropertyKey, beforeChildKey: PropertyKey | undefined, afterChildKey: PropertyKey | undefined, + backwardCompatibility: BwcState = ctx.backwardCompatibility, ): CompareContext => { const { before, after, rules, options, scope } = ctx let beforeContext: NodeContext @@ -137,7 +139,7 @@ export const createChildContext = ( ) ?? {}, options, scope: scope, - backwardCompatibility: ctx.backwardCompatibility, + backwardCompatibility, } } @@ -161,7 +163,19 @@ const cleanUpRecursive = (ctx: NodeContext): NodeContext => { } export const getOrCreateChildDiffAdd = (diffUniquenessCache: EvaluationCacheService, childCtx: CompareContext) => { - const diff = diffUniquenessCache.cacheEvaluationResultByFootprint<[unknown, string, CompareScope, typeof DiffAction.add], DiffAdd>([childCtx.after.value, buildPathsIdentifier(childCtx.after.declarativePaths), childCtx.scope, DiffAction.add], () => { + const diff = diffUniquenessCache.cacheEvaluationResultByFootprint<[ + unknown, + string, + CompareScope, + typeof DiffAction.add, + ApiKind + ], DiffAdd>([ + childCtx.after.value, + buildPathsIdentifier(childCtx.after.declarativePaths), + childCtx.scope, + DiffAction.add, + childCtx.backwardCompatibility, + ], () => { return diffFactory.added(childCtx) }, {} as DiffAdd, (result, guard) => { Object.assign(guard, result) @@ -172,7 +186,19 @@ export const getOrCreateChildDiffAdd = (diffUniquenessCache: EvaluationCacheServ } export const getOrCreateChildDiffRemove = (diffUniquenessCache: EvaluationCacheService, childCtx: CompareContext) => { - const diff = diffUniquenessCache.cacheEvaluationResultByFootprint<[unknown, string, CompareScope, typeof DiffAction.remove], DiffRemove>([childCtx.before.value, buildPathsIdentifier(childCtx.before.declarativePaths), childCtx.scope, DiffAction.remove], () => { + const diff = diffUniquenessCache.cacheEvaluationResultByFootprint<[ + unknown, + string, + CompareScope, + typeof DiffAction.remove, + ApiKind + ], DiffRemove>([ + childCtx.before.value, + buildPathsIdentifier(childCtx.before.declarativePaths), + childCtx.scope, + DiffAction.remove, + childCtx.backwardCompatibility, + ], () => { return diffFactory.removed(childCtx) }, {} as DiffRemove, (result, guard) => { Object.assign(guard, result) @@ -258,7 +284,7 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): afterValueAdapted, ] = adaptValues(beforeJso, beforeKey, afterJso, afterKey, adapter, options) - const backwardCompatibility = bwcScopeFunction?.(crawlContext.path) ?? currentBackwardCompatibility + const backwardCompatibility = bwcScopeFunction?.(crawlContext.path, beforeValueAdapted, afterValueAdapted) ?? currentBackwardCompatibility const ctx = createContext({ ...state, @@ -334,13 +360,19 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): once = true keyToRemove.forEach((keyToBefore) => { - const childCtx = createChildContext(ctx, keyToBefore, keyToBefore, undefined) + const removalBwc = backwardCompatibility === ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE + ? backwardCompatibility + : bwcScopeFunction?.(crawlContext.path, beforeValue[keyToBefore]) + const childCtx = createChildContext(ctx, keyToBefore, keyToBefore, undefined, removalBwc) jsoDiffEntries.push(getOrCreateChildDiffRemove(diffUniquenessCache, childCtx)) }) keysToAdd.forEach((keyInAfter) => { + const additionBwc = backwardCompatibility === ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE + ? backwardCompatibility + : bwcScopeFunction?.(crawlContext.path, undefined, afterJso[keyInAfter]) const keyInMerge = isArray(mergedJsoValue) ? mergedJsoValue.length : keyInAfter - const childCtx = createChildContext(ctx, keyInMerge, undefined, keyInAfter) + const childCtx = createChildContext(ctx, keyInMerge, undefined, keyInAfter, additionBwc) jsoDiffEntries.push(getOrCreateChildDiffAdd(diffUniquenessCache, childCtx)) mergedJsoValue[keyInMerge] = afterValue[keyInAfter] }) @@ -573,7 +605,7 @@ const compareInternal = (before: unknown, after: unknown, onDiff: DiffCallback, diffUniquenessCache: options.diffUniquenessCache, createdMergedJso: options.createdMergedJso, compareScope: options.compareScope, - backwardCompatibility: API_KIND.BACKWARD_COMPATIBLE, + backwardCompatibility: ApiCompatibilityKind.BACKWARD_COMPATIBLE, } syncCrawl(before, [hook], { state: rootState, rules: options.rules }) return root.merged[JSO_ROOT] diff --git a/src/core/diff.ts b/src/core/diff.ts index 14e0825..1dd9c96 100644 --- a/src/core/diff.ts +++ b/src/core/diff.ts @@ -1,7 +1,7 @@ import { JsonPath } from '@netcracker/qubership-apihub-json-crawl' import { - API_KIND, + ApiCompatibilityKind, CompareContext, Diff, DiffAdd, @@ -46,7 +46,7 @@ export const createDiff = (diff: Omit, ctx: CompareCo } export const reclassifyTypeToRisky = (type: DiffType, ctx: CompareContext): DiffType => { - return type === breaking && ctx.backwardCompatibility === API_KIND.NOT_BACKWARD_COMPATIBLE ? risky : type + return type === breaking && ctx.backwardCompatibility === ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE ? risky : type } export function createDiffEntry(ctx: CompareContext, diff: Diff): DiffEntry { diff --git a/src/index.ts b/src/index.ts index 06ea092..b3384cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,7 @@ export type { CompareOptions, ApiKind, BwcScopeFunction, - BackwardCompatibilityState, + BwcState, DiffType, ActionType, Diff, @@ -29,7 +29,7 @@ export type { DiffRename, DiffMetaRecord, } from './types' -export { API_KIND } from './types' +export { ApiCompatibilityKind } from './types' export { isDiffAdd, diff --git a/src/types/compare.ts b/src/types/compare.ts index 85b6f06..f40e6eb 100644 --- a/src/types/compare.ts +++ b/src/types/compare.ts @@ -83,16 +83,16 @@ export const COMPARE_MODE_OPERATION = 'operation' export type CompareMode = typeof COMPARE_MODE_DEFAULT | typeof COMPARE_MODE_OPERATION -export enum API_KIND { +export enum ApiCompatibilityKind { BACKWARD_COMPATIBLE = 'BACKWARD_COMPATIBLE', NOT_BACKWARD_COMPATIBLE = 'NOT_BACKWARD_COMPATIBLE' } -export type BackwardCompatibilityState = - | API_KIND.BACKWARD_COMPATIBLE - | API_KIND.NOT_BACKWARD_COMPATIBLE +export type BwcState = + | ApiCompatibilityKind.BACKWARD_COMPATIBLE + | ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE -export type BwcScopeFunction = (path?: PropertyKey[]) => BackwardCompatibilityState | undefined +export type BwcScopeFunction = (path?: JsonPath, beforeJso?: unknown, afterJso?: unknown) => BwcState | undefined export interface CompareOptions extends Omit { mode?: CompareMode @@ -130,7 +130,7 @@ export type CompareEngine = (before: unknown, after: unknown, options: StrictCom export type NodeRoot = { [JSO_ROOT]: any } export type KeyMapping = Record -export type ApiKind = API_KIND +export type ApiKind = ApiCompatibilityKind export interface MergeState { parentContext: CompareContext | undefined @@ -147,7 +147,7 @@ export interface MergeState { diffUniquenessCache: EvaluationCacheService, createdMergedJso: Set, compareScope: CompareScope - backwardCompatibility: BackwardCompatibilityState + backwardCompatibility: BwcState } export type JsonNode = Key extends (string | symbol) ? Record : Record | Array diff --git a/src/types/rules.ts b/src/types/rules.ts index 9f19ff6..cef035d 100644 --- a/src/types/rules.ts +++ b/src/types/rules.ts @@ -1,7 +1,7 @@ import { CrawlRules, JsonPath } from '@netcracker/qubership-apihub-json-crawl' import type { CompareResult, Diff, DiffType } from './compare' -import { BackwardCompatibilityState, CompareScope, InternalCompareOptions } from './compare' +import { BwcState, CompareScope, InternalCompareOptions } from './compare' import { DiffAction } from '../core' import { OriginLeafs } from '@netcracker/qubership-apihub-api-unifier' @@ -44,7 +44,7 @@ export interface CompareContext { mergeKey: PropertyKey rules: CompareRules options: InternalCompareOptions - backwardCompatibility: BackwardCompatibilityState + backwardCompatibility: BwcState } export interface AdapterContext { From 3a7822804ae87c0cf55cf3bfb92e3f0eeb112b7d Mon Sep 17 00:00:00 2001 From: Pavel Makeev Date: Fri, 12 Dec 2025 10:09:50 +0400 Subject: [PATCH 5/6] feat: Updated tests --- src/core/compare.ts | 26 ++- src/index.ts | 1 - src/types/compare.ts | 6 +- test/backward-compatibility.test.ts | 174 +++++++++++++----- .../backward-compatibility/case5/after.json | 113 ------------ .../backward-compatibility/case5/before.json | 113 ------------ .../after.json | 42 ++--- .../before.json | 42 ++--- .../multiple-methods-response-ref/after.json | 55 ++++++ .../multiple-methods-response-ref/before.json | 55 ++++++ .../after.json | 0 .../before.json | 0 .../after.json | 19 +- .../before.json | 19 +- .../after.json | 0 .../before.json | 0 16 files changed, 314 insertions(+), 351 deletions(-) delete mode 100644 test/helper/resources/backward-compatibility/case5/after.json delete mode 100644 test/helper/resources/backward-compatibility/case5/before.json rename test/helper/resources/backward-compatibility/{case4 => multiple-methods-request-response-ref}/after.json (56%) rename test/helper/resources/backward-compatibility/{case4 => multiple-methods-request-response-ref}/before.json (55%) create mode 100644 test/helper/resources/backward-compatibility/multiple-methods-response-ref/after.json create mode 100644 test/helper/resources/backward-compatibility/multiple-methods-response-ref/before.json rename test/helper/resources/backward-compatibility/{case3 => multiple-methods-response}/after.json (100%) rename test/helper/resources/backward-compatibility/{case3 => multiple-methods-response}/before.json (100%) rename test/helper/resources/backward-compatibility/{case6 => single-method-request-response}/after.json (58%) rename test/helper/resources/backward-compatibility/{case6 => single-method-request-response}/before.json (58%) rename test/helper/resources/backward-compatibility/{case2 => single-method-response}/after.json (100%) rename test/helper/resources/backward-compatibility/{case2 => single-method-response}/before.json (100%) diff --git a/src/core/compare.ts b/src/core/compare.ts index a781d73..07c4f70 100644 --- a/src/core/compare.ts +++ b/src/core/compare.ts @@ -16,7 +16,6 @@ import { deepEqual } from 'fast-equals' import { AdapterContext, AdapterResolver, - ApiKind, BwcState, CompareContext, CompareResult, @@ -168,7 +167,7 @@ export const getOrCreateChildDiffAdd = (diffUniquenessCache: EvaluationCacheServ string, CompareScope, typeof DiffAction.add, - ApiKind + BwcState ], DiffAdd>([ childCtx.after.value, buildPathsIdentifier(childCtx.after.declarativePaths), @@ -191,7 +190,7 @@ export const getOrCreateChildDiffRemove = (diffUniquenessCache: EvaluationCacheS string, CompareScope, typeof DiffAction.remove, - ApiKind + BwcState ], DiffRemove>([ childCtx.before.value, buildPathsIdentifier(childCtx.before.declarativePaths), @@ -260,7 +259,7 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): diffUniquenessCache, createdMergedJso, compareScope, - backwardCompatibility: currentBackwardCompatibility, + backwardCompatibility: parentBwc, } = state if (typeof unsafeKey === 'symbol') { @@ -284,7 +283,7 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): afterValueAdapted, ] = adaptValues(beforeJso, beforeKey, afterJso, afterKey, adapter, options) - const backwardCompatibility = bwcScopeFunction?.(crawlContext.path, beforeValueAdapted, afterValueAdapted) ?? currentBackwardCompatibility + const computedBwc = bwcScopeFunction?.(crawlContext.path, beforeValueAdapted, afterValueAdapted) ?? parentBwc const ctx = createContext({ ...state, @@ -295,12 +294,11 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): mergeKey, rules, compareScope: newCompareScope ?? compareScope, - backwardCompatibility, + backwardCompatibility: computedBwc, }, options) const beforeDeclarativePathsId = buildPathsIdentifier(cleanUpRecursive(ctx.before).declarativePaths) const afterDeclarativePathsId = buildPathsIdentifier(cleanUpRecursive(ctx.after).declarativePaths) - const apiKind: ApiKind = backwardCompatibility const reuseResult: ReusableMergeResult = mergedJsoCache.cacheEvaluationResultByFootprint<[ typeof ctx.before.value, @@ -308,14 +306,14 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): typeof beforeDeclarativePathsId, typeof afterDeclarativePathsId, CompareScope, - ApiKind + BwcState ], ReusableMergeResult>([ ctx.before.value, ctx.after.value, beforeDeclarativePathsId, afterDeclarativePathsId, ctx.scope, - apiKind, + computedBwc, ], ([beforeValue, afterValue]) => { if (!ignoreKeyDifference && beforeKey !== afterKey) { const diffEntry = createDiffEntry(ctx, diffFactory.renamed(ctx)) @@ -360,16 +358,16 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): once = true keyToRemove.forEach((keyToBefore) => { - const removalBwc = backwardCompatibility === ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE - ? backwardCompatibility + const removalBwc = computedBwc === ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE + ? computedBwc : bwcScopeFunction?.(crawlContext.path, beforeValue[keyToBefore]) const childCtx = createChildContext(ctx, keyToBefore, keyToBefore, undefined, removalBwc) jsoDiffEntries.push(getOrCreateChildDiffRemove(diffUniquenessCache, childCtx)) }) keysToAdd.forEach((keyInAfter) => { - const additionBwc = backwardCompatibility === ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE - ? backwardCompatibility + const additionBwc = computedBwc === ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE + ? computedBwc : bwcScopeFunction?.(crawlContext.path, undefined, afterJso[keyInAfter]) const keyInMerge = isArray(mergedJsoValue) ? mergedJsoValue.length : keyInAfter const childCtx = createChildContext(ctx, keyInMerge, undefined, keyInAfter, additionBwc) @@ -419,7 +417,7 @@ const useMergeFactory = (onDiff: DiffCallback, options: InternalCompareOptions): afterJso: afterValueAdapted as JsonNode/*safe cause it only happens for object*/, mergedJso: mergedValue, compareScope: newCompareScope ?? compareScope, - backwardCompatibility, + backwardCompatibility: computedBwc, } return { value: reuseResult.nextValue, state: childState, exitHook: reuseResult.exitHook } } else { diff --git a/src/index.ts b/src/index.ts index b3384cf..39ce05d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,6 @@ export { apiDiff } from './api' export type { CompareResult, CompareOptions, - ApiKind, BwcScopeFunction, BwcState, DiffType, diff --git a/src/types/compare.ts b/src/types/compare.ts index f40e6eb..5677f51 100644 --- a/src/types/compare.ts +++ b/src/types/compare.ts @@ -88,9 +88,7 @@ export enum ApiCompatibilityKind { NOT_BACKWARD_COMPATIBLE = 'NOT_BACKWARD_COMPATIBLE' } -export type BwcState = - | ApiCompatibilityKind.BACKWARD_COMPATIBLE - | ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE +export type BwcState = keyof typeof ApiCompatibilityKind export type BwcScopeFunction = (path?: JsonPath, beforeJso?: unknown, afterJso?: unknown) => BwcState | undefined @@ -130,8 +128,6 @@ export type CompareEngine = (before: unknown, after: unknown, options: StrictCom export type NodeRoot = { [JSO_ROOT]: any } export type KeyMapping = Record -export type ApiKind = ApiCompatibilityKind - export interface MergeState { parentContext: CompareContext | undefined keyMap: KeyMapping // parent keys mappings diff --git a/test/backward-compatibility.test.ts b/test/backward-compatibility.test.ts index 7e6960f..1b0a3c2 100644 --- a/test/backward-compatibility.test.ts +++ b/test/backward-compatibility.test.ts @@ -1,25 +1,34 @@ -import { API_KIND, apiDiff, breaking, BwcScopeFunction, DiffAction, risky } from '../src' +import { ApiCompatibilityKind, apiDiff, breaking, BwcScopeFunction, DiffAction, risky } from '../src' -import case2Before from './helper/resources/backward-compatibility/case2/before.json' -import case2After from './helper/resources/backward-compatibility/case2/after.json' +import singleMethodResponseBefore from './helper/resources/backward-compatibility/single-method-response/before.json' +import singleMethodResponseAfter from './helper/resources/backward-compatibility/single-method-response/after.json' -import case3Before from './helper/resources/backward-compatibility/case3/before.json' -import case3After from './helper/resources/backward-compatibility/case3/after.json' +import singleMethodRequestResponseBefore + from './helper/resources/backward-compatibility/single-method-request-response/before.json' +import singleMethodRequestResponseAfter + from './helper/resources/backward-compatibility/single-method-request-response/after.json' -import case4Before from './helper/resources/backward-compatibility/case4/before.json' -import case4After from './helper/resources/backward-compatibility/case4/after.json' +import multipleMethodsResponseBefore + from './helper/resources/backward-compatibility/multiple-methods-response/before.json' +import multipleMethodsResponseAfter + from './helper/resources/backward-compatibility/multiple-methods-response/after.json' -import case5Before from './helper/resources/backward-compatibility/case5/before.json' -import case5After from './helper/resources/backward-compatibility/case5/after.json' +import multipleMethodsResponseRefBefore + from './helper/resources/backward-compatibility/multiple-methods-response-ref/before.json' +import multipleMethodsResponseRefAfter + from './helper/resources/backward-compatibility/multiple-methods-response-ref/after.json' -import case6Before from './helper/resources/backward-compatibility/case6/before.json' -import case6After from './helper/resources/backward-compatibility/case6/after.json' +import multipleMethodsRequestResponseRefBefore + from './helper/resources/backward-compatibility/multiple-methods-request-response-ref/before.json' +import multipleMethodsRequestResponseRefAfter + from './helper/resources/backward-compatibility/multiple-methods-request-response-ref/after.json' import { diffsMatcher } from './helper/matchers' type PATH_ENTRY = [string, string, string] const GET_PATH1: PATH_ENTRY = ['paths', '/path1', 'get'] +const POST_PATH1: PATH_ENTRY = ['paths', '/path1', 'post'] function createBwcScopeFunction(data: PATH_ENTRY[]): BwcScopeFunction { return (path?: PropertyKey[]) => { @@ -28,29 +37,51 @@ function createBwcScopeFunction(data: PATH_ENTRY[]): BwcScopeFunction { } return data.some(entry => entry.every((el, i) => path?.[i] === el), - ) ? API_KIND.NOT_BACKWARD_COMPATIBLE : undefined + ) ? ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE : undefined } } describe('Backward compatibility tests', () => { - it('should diff from get has risky type with NoApiBackwardCompatibility', async () => { - const { diffs } = apiDiff(case2Before, case2After, { bwcScopeFunction: () => API_KIND.NOT_BACKWARD_COMPATIBLE }) + it('should diff from get has risky type with not backward compatible', async () => { + const { diffs } = apiDiff( + singleMethodResponseBefore, + singleMethodResponseAfter, + { bwcScopeFunction: () => ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE }, + ) + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + ])) + }) + + it('should mark both request and response as risky for single method with not backward compatible', async () => { + const { diffs } = apiDiff( + singleMethodRequestResponseBefore, + singleMethodRequestResponseAfter, + { bwcScopeFunction: () => ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE }, + ) expect(diffs).toEqual(diffsMatcher([ expect.objectContaining({ scope: 'response', action: DiffAction.replace, type: risky, }), + expect.objectContaining({ + scope: 'request', + action: DiffAction.replace, + type: risky, + }), ])) }) - it('3', async () => { + it('should mark GET method as risky and POST as breaking when only GET is in scope', async () => { const { diffs } = apiDiff( - case3Before, - case3After, - { - bwcScopeFunction: createBwcScopeFunction([GET_PATH1]), - }) + multipleMethodsResponseBefore, + multipleMethodsResponseAfter, + { bwcScopeFunction: createBwcScopeFunction([GET_PATH1]) }) expect(diffs).toEqual(diffsMatcher([ expect.objectContaining({ scope: 'response', @@ -65,19 +96,57 @@ describe('Backward compatibility tests', () => { ])) }) - it('4', async () => { + it('should mark POST method as breaking and GET as risky when only POST is in scope', async () => { + const { diffs } = apiDiff( + multipleMethodsResponseBefore, + multipleMethodsResponseAfter, + { bwcScopeFunction: createBwcScopeFunction([POST_PATH1]) }) + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: breaking, + }), + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + ])) + }) + + it('should mark both GET and POST methods as risky when both are in scope', async () => { + const { diffs } = apiDiff( + multipleMethodsResponseBefore, + multipleMethodsResponseAfter, + { bwcScopeFunction: createBwcScopeFunction([GET_PATH1, POST_PATH1]) }) + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + ])) + }) + + it('should have two response diffs and one components diff when only GET is in scope with refs', async () => { const { diffs } = apiDiff( - case4Before, - case4After, - { - bwcScopeFunction: createBwcScopeFunction([GET_PATH1]), - }) + multipleMethodsResponseRefBefore, + multipleMethodsResponseRefAfter, + { bwcScopeFunction: createBwcScopeFunction([GET_PATH1]) }) expect(diffs).toEqual(diffsMatcher([ + // post expect.objectContaining({ scope: 'response', action: DiffAction.replace, type: breaking, }), + // get expect.objectContaining({ scope: 'response', action: DiffAction.replace, @@ -91,13 +160,31 @@ describe('Backward compatibility tests', () => { ])) }) - it('5', async () => { + it('should have one response diff and one components diff when both methods are in scope with refs', async () => { const { diffs } = apiDiff( - case5Before, - case5After, - { - bwcScopeFunction: createBwcScopeFunction([GET_PATH1]), - }) + multipleMethodsResponseRefBefore, + multipleMethodsResponseRefAfter, + { bwcScopeFunction: createBwcScopeFunction([GET_PATH1, POST_PATH1]) }) + expect(diffs).toEqual(diffsMatcher([ + // get + post + expect.objectContaining({ + scope: 'response', + action: DiffAction.replace, + type: risky, + }), + expect.objectContaining({ + scope: 'components', + action: DiffAction.replace, + type: breaking, + }), + ])) + }) + + it('should mark POST as breaking and GET as risky for both request and response when only GET is in scope with refs', async () => { + const { diffs } = apiDiff( + multipleMethodsRequestResponseRefBefore, + multipleMethodsRequestResponseRefAfter, + { bwcScopeFunction: createBwcScopeFunction([GET_PATH1]) }) expect(diffs).toEqual(diffsMatcher([ // post expect.objectContaining({ @@ -131,28 +218,29 @@ describe('Backward compatibility tests', () => { ])) }) - it('6', async () => { + it('should mark both request and response as risky when both methods are in scope with refs', async () => { const { diffs } = apiDiff( - case6Before, - case6After, - { - bwcScopeFunction: createBwcScopeFunction([GET_PATH1]), - }) + multipleMethodsRequestResponseRefBefore, + multipleMethodsRequestResponseRefAfter, + { bwcScopeFunction: createBwcScopeFunction([GET_PATH1, POST_PATH1]) }) expect(diffs).toEqual(diffsMatcher([ + // get + post expect.objectContaining({ - beforeValue: 'number', - afterValue: 'string', - scope: 'response', + scope: 'request', action: DiffAction.replace, type: risky, }), + // get + post expect.objectContaining({ - beforeValue: 'string', - afterValue: 'number', scope: 'response', action: DiffAction.replace, type: risky, }), + expect.objectContaining({ + scope: 'components', + action: DiffAction.replace, + type: breaking, + }), ])) }) }) diff --git a/test/helper/resources/backward-compatibility/case5/after.json b/test/helper/resources/backward-compatibility/case5/after.json deleted file mode 100644 index be33f43..0000000 --- a/test/helper/resources/backward-compatibility/case5/after.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sample API", - "description": "API specification example", - "version": "1.0.0" - }, - "paths": { - "/path1": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } - } - } - } - } - } - }, - "get": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "componentsValue": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "currency": { - "type": "string" - } - } - } - } - } -} diff --git a/test/helper/resources/backward-compatibility/case5/before.json b/test/helper/resources/backward-compatibility/case5/before.json deleted file mode 100644 index c395db8..0000000 --- a/test/helper/resources/backward-compatibility/case5/before.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sample API", - "description": "API specification example", - "version": "1.0.0" - }, - "paths": { - "/path1": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } - } - } - } - } - } - }, - "get": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "componentsValue": { - "type": "object", - "properties": { - "value": { - "type": "number" - }, - "currency": { - "type": "string" - } - } - } - } - } -} \ No newline at end of file diff --git a/test/helper/resources/backward-compatibility/case4/after.json b/test/helper/resources/backward-compatibility/multiple-methods-request-response-ref/after.json similarity index 56% rename from test/helper/resources/backward-compatibility/case4/after.json rename to test/helper/resources/backward-compatibility/multiple-methods-request-response-ref/after.json index f1086dc..d29eca1 100644 --- a/test/helper/resources/backward-compatibility/case4/after.json +++ b/test/helper/resources/backward-compatibility/multiple-methods-request-response-ref/after.json @@ -8,23 +8,22 @@ "paths": { "/path1": { "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } + "$ref": "#/components/schemas/componentsValue" } } } @@ -32,23 +31,22 @@ } }, "get": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } + "$ref": "#/components/schemas/componentsValue" } } } diff --git a/test/helper/resources/backward-compatibility/case4/before.json b/test/helper/resources/backward-compatibility/multiple-methods-request-response-ref/before.json similarity index 55% rename from test/helper/resources/backward-compatibility/case4/before.json rename to test/helper/resources/backward-compatibility/multiple-methods-request-response-ref/before.json index 77cb225..da5eab3 100644 --- a/test/helper/resources/backward-compatibility/case4/before.json +++ b/test/helper/resources/backward-compatibility/multiple-methods-request-response-ref/before.json @@ -8,23 +8,22 @@ "paths": { "/path1": { "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } + "$ref": "#/components/schemas/componentsValue" } } } @@ -32,23 +31,22 @@ } }, "get": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "price": { - "type": "object", - "properties": { - "prop1": { - "$ref": "#/components/schemas/componentsValue" - } - } - } - } + "$ref": "#/components/schemas/componentsValue" } } } diff --git a/test/helper/resources/backward-compatibility/multiple-methods-response-ref/after.json b/test/helper/resources/backward-compatibility/multiple-methods-response-ref/after.json new file mode 100644 index 0000000..3cb610d --- /dev/null +++ b/test/helper/resources/backward-compatibility/multiple-methods-response-ref/after.json @@ -0,0 +1,55 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Sample API", + "description": "API specification example", + "version": "1.0.0" + }, + "paths": { + "/path1": { + "post": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + }, + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "componentsValue": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "currency": { + "type": "string" + } + } + } + } + } +} diff --git a/test/helper/resources/backward-compatibility/multiple-methods-response-ref/before.json b/test/helper/resources/backward-compatibility/multiple-methods-response-ref/before.json new file mode 100644 index 0000000..875a78d --- /dev/null +++ b/test/helper/resources/backward-compatibility/multiple-methods-response-ref/before.json @@ -0,0 +1,55 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Sample API", + "description": "API specification example", + "version": "1.0.0" + }, + "paths": { + "/path1": { + "post": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + }, + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/componentsValue" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "componentsValue": { + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "currency": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/test/helper/resources/backward-compatibility/case3/after.json b/test/helper/resources/backward-compatibility/multiple-methods-response/after.json similarity index 100% rename from test/helper/resources/backward-compatibility/case3/after.json rename to test/helper/resources/backward-compatibility/multiple-methods-response/after.json diff --git a/test/helper/resources/backward-compatibility/case3/before.json b/test/helper/resources/backward-compatibility/multiple-methods-response/before.json similarity index 100% rename from test/helper/resources/backward-compatibility/case3/before.json rename to test/helper/resources/backward-compatibility/multiple-methods-response/before.json diff --git a/test/helper/resources/backward-compatibility/case6/after.json b/test/helper/resources/backward-compatibility/single-method-request-response/after.json similarity index 58% rename from test/helper/resources/backward-compatibility/case6/after.json rename to test/helper/resources/backward-compatibility/single-method-request-response/after.json index 45b4723..7af775f 100644 --- a/test/helper/resources/backward-compatibility/case6/after.json +++ b/test/helper/resources/backward-compatibility/single-method-request-response/after.json @@ -7,21 +7,22 @@ "paths": { "/path1": { "get": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "field1": { - "type": "string" - }, - "field2": { - "type": "number" - } - } + "type": "string" } } } diff --git a/test/helper/resources/backward-compatibility/case6/before.json b/test/helper/resources/backward-compatibility/single-method-request-response/before.json similarity index 58% rename from test/helper/resources/backward-compatibility/case6/before.json rename to test/helper/resources/backward-compatibility/single-method-request-response/before.json index 5a34bc1..d72f57c 100644 --- a/test/helper/resources/backward-compatibility/case6/before.json +++ b/test/helper/resources/backward-compatibility/single-method-request-response/before.json @@ -7,21 +7,22 @@ "paths": { "/path1": { "get": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "number" + } + } + } + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "field1": { - "type": "number" - }, - "field2": { - "type": "string" - } - } + "type": "number" } } } diff --git a/test/helper/resources/backward-compatibility/case2/after.json b/test/helper/resources/backward-compatibility/single-method-response/after.json similarity index 100% rename from test/helper/resources/backward-compatibility/case2/after.json rename to test/helper/resources/backward-compatibility/single-method-response/after.json diff --git a/test/helper/resources/backward-compatibility/case2/before.json b/test/helper/resources/backward-compatibility/single-method-response/before.json similarity index 100% rename from test/helper/resources/backward-compatibility/case2/before.json rename to test/helper/resources/backward-compatibility/single-method-response/before.json From b7382905f124f027250235e451faaea55220e917 Mon Sep 17 00:00:00 2001 From: Pavel Makeev Date: Fri, 12 Dec 2025 11:11:42 +0400 Subject: [PATCH 6/6] feat: Refactoring --- src/core/compare.ts | 4 ++-- src/core/diff.ts | 4 ++-- src/jsonSchema/jsonSchema.classify.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/compare.ts b/src/core/compare.ts index 07c4f70..502d36c 100644 --- a/src/core/compare.ts +++ b/src/core/compare.ts @@ -494,7 +494,7 @@ function addNormalizedValuesToDenormalizedDiff( denormalizedDiffs: Diff[], rawDiffs: Diff[], beforeValueNormalizedProperty?: symbol, - afterValueNormalizedProperty?: symbol, + afterValueNormalizedProperty?: symbol ) { for (let i = 0; i < denormalizedDiffs.length && i < rawDiffs.length; i++) { const denormalizedDiff = denormalizedDiffs[i] @@ -562,7 +562,7 @@ export const compare = (before: unknown, after: unknown, options: InternalCompar denormalizedDiffs, rawDiffs, options.beforeValueNormalizedProperty, - options.afterValueNormalizedProperty, + options.afterValueNormalizedProperty ) return { diffs: denormalizedDiffs, diff --git a/src/core/diff.ts b/src/core/diff.ts index 1dd9c96..d3ada72 100644 --- a/src/core/diff.ts +++ b/src/core/diff.ts @@ -32,7 +32,7 @@ export const createDiff = (diff: Omit, ctx: CompareCo try { const type = isFunc(changeType) ? changeType(ctx) : changeType - mutableDiffCopy.type = reclassifyTypeToRisky(type, ctx) + mutableDiffCopy.type = reclassifyBreakingToRisky(type, ctx) } catch (error) { ctx.options.onCreateDiffError?.(`Unable to find diff type. ${error instanceof Error ? error.message : ''}`, mutableDiffCopy, ctx) } @@ -45,7 +45,7 @@ export const createDiff = (diff: Omit, ctx: CompareCo return mutableDiffCopy } -export const reclassifyTypeToRisky = (type: DiffType, ctx: CompareContext): DiffType => { +export const reclassifyBreakingToRisky = (type: DiffType, ctx: CompareContext): DiffType => { return type === breaking && ctx.backwardCompatibility === ApiCompatibilityKind.NOT_BACKWARD_COMPATIBLE ? risky : type } diff --git a/src/jsonSchema/jsonSchema.classify.ts b/src/jsonSchema/jsonSchema.classify.ts index 0abdfce..c759ced 100644 --- a/src/jsonSchema/jsonSchema.classify.ts +++ b/src/jsonSchema/jsonSchema.classify.ts @@ -23,10 +23,10 @@ import type { ClassifyRule } from '../types' export const typeClassifier: ClassifyRule = [ breaking,//not tested breaking,//not tested - ({ before, after }) => (nonBreakingIf(isTypeAssignable(before.value, after.value, false))), + ({ before, after }) => nonBreakingIf(isTypeAssignable(before.value, after.value, false)), breaking,//not tested breaking,//not tested - ({ before, after }) => (nonBreakingIf(isTypeAssignable(before.value, after.value, true))), + ({ before, after }) => nonBreakingIf(isTypeAssignable(before.value, after.value, true)), ] export const maxClassifier: ClassifyRule = [