Skip to content

Commit 7603ded

Browse files
committed
feat: add initial version of adapter for comparing OAS 3.0 to 3.1 JSON schemas
1 parent 72ef0e1 commit 7603ded

File tree

8 files changed

+179
-45
lines changed

8 files changed

+179
-45
lines changed

src/api.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ function areSpecTypesCompatible(beforeType: SpecType, afterType: SpecType): bool
3131
return isOpenApiSpecVersion(beforeType) && isOpenApiSpecVersion(afterType)
3232
}
3333

34+
function selectEngineSpecType(beforeType: SpecType, afterType: SpecType): SpecType {
35+
// For OpenAPI version comparisons, use the higher version
36+
if (isOpenApiSpecVersion(beforeType) && isOpenApiSpecVersion(afterType)) {
37+
if (beforeType === SPEC_TYPE_OPEN_API_31 || afterType === SPEC_TYPE_OPEN_API_31) {
38+
return SPEC_TYPE_OPEN_API_31
39+
}
40+
return SPEC_TYPE_OPEN_API_30
41+
}
42+
43+
// For same spec types or other compatible types, use the before type
44+
return beforeType
45+
}
46+
3447
export const COMPARE_ENGINES_MAP: Record<SpecType, CompareEngine> = {
3548
[SPEC_TYPE_JSON_SCHEMA_04]: compareJsonSchema(SPEC_TYPE_JSON_SCHEMA_04),
3649
[SPEC_TYPE_JSON_SCHEMA_06]: compareJsonSchema(SPEC_TYPE_JSON_SCHEMA_06),
@@ -48,7 +61,7 @@ export function apiDiff(before: unknown, after: unknown, options: CompareOptions
4861
if (!areSpecTypesCompatible(beforeSpec.type, afterSpec.type)) {
4962
throw new Error(`Specification cannot be different. Got ${beforeSpec.type} and ${afterSpec.type}`)
5063
}
51-
const engine = COMPARE_ENGINES_MAP[beforeSpec.type]
64+
const engine = COMPARE_ENGINES_MAP[selectEngineSpecType(beforeSpec.type, afterSpec.type)]
5265
return engine(before, after, {
5366
mode: COMPARE_MODE_DEFAULT,
5467
normalizedResult: DEFAULT_NORMALIZED_RESULT,

src/openapi/openapi3.schema.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
transformCompareRules,
1717
} from '../core'
1818
import type { OpenApi3SchemaRulesOptions } from './openapi3.types'
19-
import { CompareRules } from '../types'
19+
import { CompareRules, AdapterResolver } from '../types'
2020
import {
2121
JsonSchemaSpecVersion,
2222
normalize,
@@ -26,8 +26,15 @@ import {
2626
SPEC_TYPE_JSON_SCHEMA_07,
2727
SPEC_TYPE_OPEN_API_30,
2828
SPEC_TYPE_OPEN_API_31,
29+
JSON_SCHEMA_PROPERTY_NULLABLE,
30+
JSON_SCHEMA_PROPERTY_ANY_OF,
31+
JSON_SCHEMA_PROPERTY_ONE_OF,
32+
JSON_SCHEMA_PROPERTY_TYPE,
33+
JSON_SCHEMA_NODE_TYPE_NULL,
34+
setOrigins,
2935
} from '@netcracker/qubership-apihub-api-unifier'
3036
import { schemaParamsCalculator } from './openapi3.description.schema'
37+
import { isObject, isArray } from '../utils'
3138

3239
const SPEC_TYPE_TO_VERSION: Record<OpenApiSpecVersion, string> = {
3340
[SPEC_TYPE_OPEN_API_30]: '3.0.0',
@@ -62,13 +69,67 @@ const openApiJsonSchemaAnyFactory: (version: OpenApiSpecVersion) => NativeAnySch
6269
return normalizedSpec.components.schemas.empty as Record<PropertyKey, unknown>
6370
}
6471

72+
// Helper function to check if a schema has null type
73+
const hasNullType = (schema: unknown): boolean => {
74+
if (!isObject(schema)) return false
75+
76+
// Check direct type property
77+
const type = schema[JSON_SCHEMA_PROPERTY_TYPE]
78+
if (type === JSON_SCHEMA_NODE_TYPE_NULL) return true
79+
if (isArray(type) && type.includes(JSON_SCHEMA_NODE_TYPE_NULL)) return true
80+
81+
// Check in combiners (anyOf, oneOf)
82+
for (const combiner of [JSON_SCHEMA_PROPERTY_ANY_OF, JSON_SCHEMA_PROPERTY_ONE_OF] as const) {
83+
const combinerArray = schema[combiner]
84+
if (isArray(combinerArray)) {
85+
for (const item of combinerArray) {
86+
if (hasNullType(item)) return true
87+
}
88+
}
89+
}
90+
91+
return false
92+
}
93+
94+
// Adapter to transform JSON Schemas used in OpenAPI 3.0 schemas to JSOn schemas used in OpenAPI 3.1 for comparison
95+
const jsonSchemaOas30to31Adapter: AdapterResolver = (value, reference, ctx) => {
96+
if (!isObject(value) || !isObject(reference)) return value
97+
98+
// Check if value has nullable: true
99+
if (value[JSON_SCHEMA_PROPERTY_NULLABLE] !== true) return value
100+
101+
// Check if reference has null type or combiner with null
102+
if (!hasNullType(reference)) return value
103+
104+
// Transform to anyOf format
105+
return ctx.transformer(value, 'nullable-to-anyof', (value) => {
106+
const { [JSON_SCHEMA_PROPERTY_NULLABLE]: _, ...valueWithoutNullable } = value as Record<PropertyKey, unknown>
107+
108+
// Create the anyOf array with the original schema (without nullable) and null type
109+
const anyOfArray = [
110+
valueWithoutNullable,
111+
{ [JSON_SCHEMA_PROPERTY_TYPE]: JSON_SCHEMA_NODE_TYPE_NULL }
112+
]
113+
114+
const result = { [JSON_SCHEMA_PROPERTY_ANY_OF]: anyOfArray }
115+
116+
// Set origins for the anyOf property and its items
117+
setOrigins(result, JSON_SCHEMA_PROPERTY_ANY_OF, ctx.options.originsFlag, ctx.valueOrigins)
118+
setOrigins(anyOfArray, 0, ctx.options.originsFlag, ctx.valueOrigins)
119+
setOrigins(anyOfArray, 1, ctx.options.originsFlag, ctx.valueOrigins)
120+
121+
return result
122+
})
123+
}
124+
65125
export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions): CompareRules => {
66126
//todo find better way to remove this 'version specific' copy-paste with normalize
67127
const jsonSchemaVersion: JsonSchemaSpecVersion = options.version === SPEC_TYPE_OPEN_API_30 ? SPEC_TYPE_JSON_SCHEMA_04 : SPEC_TYPE_JSON_SCHEMA_07
68128

69129
const schemaRules = jsonSchemaRules({
70130
additionalRules: {
71131
adapter: [
132+
...(options.version === SPEC_TYPE_OPEN_API_31 ? [jsonSchemaOas30to31Adapter] : []),
72133
jsonSchemaAdapter(openApiJsonSchemaAnyFactory(options.version)),
73134
],
74135
descriptionParamCalculator: schemaParamsCalculator,

test/helper/resources/3_0-to-3_1/add-overriden-description/before.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
openapi: 3.0.0
1+
openapi: 3.0.4
22
info:
33
title: Swagger Petstore - OpenAPI 3.1
44
version: 1.0.12

test/helper/resources/3_0-to-3_1/empty-schema/before.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
openapi: 3.0.3
1+
openapi: 3.0.4
22
info:
33
title: test
44
version: 0.1.0
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
openapi: 3.1.0
2+
info:
3+
title: test
4+
version: 0.1.0
5+
paths:
6+
/path1:
7+
get:
8+
responses:
9+
'200':
10+
description: OK
11+
content:
12+
'application/json':
13+
schema:
14+
type: 'null'
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
openapi: 3.0.4
2+
info:
3+
title: test
4+
version: 0.1.0
5+
paths:
6+
/path1:
7+
get:
8+
responses:
9+
'200':
10+
description: OK
11+
content:
12+
'application/json':
13+
schema:
14+
type: string
15+
nullable: true

test/helper/resources/3_0-to-3_1/nullable-to-union/before.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
openapi: 3.0.3
1+
openapi: 3.0.4
22
info:
33
title: test
44
version: 0.1.0

test/oas-3_0-to-3_1.test.ts

Lines changed: 71 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ import { readFileSync } from 'fs'
44
import { load } from 'js-yaml'
55
import { diffsMatcher } from './helper/matchers'
66

7+
// Helper function for OpenAPI version change assertion
8+
const expectOpenApiVersionChange = (fromVersion: string = '3.0.4', toVersion: string = '3.1.0') =>
9+
expect.objectContaining({
10+
action: 'replace',
11+
afterDeclarationPaths: [['openapi']],
12+
afterValue: toVersion,
13+
beforeDeclarationPaths: [['openapi']],
14+
beforeValue: fromVersion,
15+
type: 'annotation',
16+
})
17+
718
const TEST_NORMALIZE_OPTIONS: CompareOptions = {
819
validate: true,
920
liftCombiners: true,
@@ -14,7 +25,7 @@ const TEST_NORMALIZE_OPTIONS: CompareOptions = {
1425
allowNotValidSyntheticChanges: true,
1526
}
1627

17-
describe('OpenAPI 3.0 to 3.1 Migration Tests', () => {
28+
describe('OpenAPI 3.0 to 3.1 Comparison Tests', () => {
1829
test('empty-schema', () => {
1930
// Load the before and after files
2031
const beforePath = './test/helper/resources/3_0-to-3_1/empty-schema/before.yaml'
@@ -32,64 +43,84 @@ describe('OpenAPI 3.0 to 3.1 Migration Tests', () => {
3243

3344
// Check the result - expecting changes from empty schema {} to typed schemas
3445
expect(diffs).toEqual(diffsMatcher([
35-
expect.objectContaining({
36-
action: 'replace',
37-
afterDeclarationPaths: [['openapi']],
38-
afterValue: '3.1.0',
39-
beforeDeclarationPaths: [['openapi']],
40-
beforeValue: '3.0.3',
41-
type: 'annotation',
42-
}),
43-
]))
44-
})
45-
46-
test('nullable-to-union', () => {
47-
// Load the before and after files
48-
const beforePath = './test/helper/resources/3_0-to-3_1/nullable-to-union/before.yaml'
49-
const afterPath = './test/helper/resources/3_0-to-3_1/nullable-to-union/after.yaml'
50-
51-
const beforeSource = load(readFileSync(beforePath).toString())
52-
const afterSource = load(readFileSync(afterPath).toString())
53-
54-
// Call apiDiff
55-
const { diffs } = apiDiff(beforeSource, afterSource, {
56-
...TEST_NORMALIZE_OPTIONS,
57-
beforeSource,
58-
afterSource,
59-
})
60-
61-
// Check the result - expecting changes from nullable: true to union types
62-
expect(diffs).toEqual(diffsMatcher([
63-
// TODO: Add assertions for nullable to union type changes
46+
expectOpenApiVersionChange(),
6447
]))
65-
})
48+
})
6649

6750
test('add-overriden-description', () => {
68-
// Load the before and after files
6951
const beforePath = './test/helper/resources/3_0-to-3_1/add-overriden-description/before.yaml'
7052
const afterPath = './test/helper/resources/3_0-to-3_1/add-overriden-description/after.yaml'
7153

7254
const beforeSource = load(readFileSync(beforePath).toString())
7355
const afterSource = load(readFileSync(afterPath).toString())
7456

75-
// Call apiDiff
7657
const { diffs } = apiDiff(beforeSource, afterSource, {
7758
...TEST_NORMALIZE_OPTIONS,
7859
beforeSource,
7960
afterSource,
80-
})
61+
})
8162

82-
// Check the result - expecting OpenAPI version change and added overridden description
8363
expect(diffs).toEqual(diffsMatcher([
64+
expectOpenApiVersionChange(),
8465
expect.objectContaining({
8566
action: 'replace',
86-
afterDeclarationPaths: [['openapi']],
87-
afterValue: '3.1.0',
88-
beforeDeclarationPaths: [['openapi']],
89-
beforeValue: '3.0.0',
67+
beforeValue: 'response description from components',
68+
afterValue: 'response description override',
69+
afterDeclarationPaths: [['paths', '/path1', 'post', 'responses', '200', 'description']],
70+
beforeDeclarationPaths: [['components', 'responses', 'response200', 'description']],
9071
type: 'annotation',
9172
}),
92-
// Additional diffs for the overridden description will be added after running the test
9373
]))
9474
})
75+
76+
describe('nullable and null type', () => {
77+
test('nullable-to-union', () => {
78+
// Load the before and after files
79+
const beforePath = './test/helper/resources/3_0-to-3_1/nullable-to-union/before.yaml'
80+
const afterPath = './test/helper/resources/3_0-to-3_1/nullable-to-union/after.yaml'
81+
82+
const beforeSource = load(readFileSync(beforePath).toString())
83+
const afterSource = load(readFileSync(afterPath).toString())
84+
85+
// Call apiDiff
86+
const { diffs } = apiDiff(beforeSource, afterSource, {
87+
...TEST_NORMALIZE_OPTIONS,
88+
beforeSource,
89+
afterSource,
90+
})
91+
92+
expect(diffs.length).toBe(1)
93+
94+
expect(diffs).toEqual(diffsMatcher([
95+
expectOpenApiVersionChange(),
96+
]))
97+
})
98+
99+
test('nullable-to-null', () => {
100+
// Load the before and after files
101+
const beforePath = './test/helper/resources/3_0-to-3_1/nullable-to-null/before.yaml'
102+
const afterPath = './test/helper/resources/3_0-to-3_1/nullable-to-null/after.yaml'
103+
104+
const beforeSource = load(readFileSync(beforePath).toString())
105+
const afterSource = load(readFileSync(afterPath).toString())
106+
107+
// Call apiDiff
108+
const { diffs } = apiDiff(beforeSource, afterSource, {
109+
...TEST_NORMALIZE_OPTIONS,
110+
beforeSource,
111+
afterSource,
112+
})
113+
114+
expect(diffs).toEqual(diffsMatcher([
115+
expectOpenApiVersionChange(),
116+
expect.objectContaining({
117+
action: 'remove',
118+
//TODO: validate declaration path
119+
beforeDeclarationPaths: [['paths', '/path1', 'get', 'responses', '200', 'content', 'application/json', 'schema']],
120+
scope: 'response',
121+
type: 'non-breaking',
122+
}),
123+
]))
124+
})
125+
})
95126
})

0 commit comments

Comments
 (0)