Skip to content

Commit f6751ec

Browse files
committed
feat: OAS 3.0 to OAS 3.1 comparison
1 parent dbe7417 commit f6751ec

File tree

18 files changed

+567
-4
lines changed

18 files changed

+567
-4
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"update-lock-file": "update-lock-file @netcracker"
2828
},
2929
"dependencies": {
30-
"@netcracker/qubership-apihub-api-unifier": "dev",
30+
"@netcracker/qubership-apihub-api-unifier": "feature-oas-30-to-31-comparison",
3131
"@netcracker/qubership-apihub-json-crawl": "1.0.4",
3232
"fast-equals": "4.0.3"
3333
},

src/api.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,24 @@ function areSpecTypesCompatible(beforeType: SpecType, afterType: SpecType): bool
2626
if (beforeType === afterType) {
2727
return true
2828
}
29-
29+
3030
// Allow comparison between different OpenAPI versions
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: 133 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,138 @@ 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+
// Builds a "null" type schema carrying over relevant origins from source
95+
// const buildNullTypeWithOrigins = (
96+
// valueWithoutNullable: Record<PropertyKey, unknown>,
97+
// originsFlag: PropertyKey,
98+
// ): Record<PropertyKey, unknown> => {
99+
// const nullTypeObject: Record<PropertyKey, unknown> = {
100+
// [JSON_SCHEMA_PROPERTY_TYPE]: JSON_SCHEMA_NODE_TYPE_NULL,
101+
// }
102+
// const valueOrigins = valueWithoutNullable[originsFlag] as Record<PropertyKey, unknown> | undefined
103+
//
104+
// if (valueWithoutNullable.title) {
105+
// nullTypeObject.title = valueWithoutNullable.title
106+
// const titleOrigin = valueOrigins && (valueOrigins as Record<PropertyKey, unknown>).title
107+
// if (titleOrigin !== undefined) {
108+
// nullTypeObject[originsFlag] = { title: titleOrigin }
109+
// }
110+
// }
111+
//
112+
// const nullableOrigin = valueOrigins && (valueOrigins as Record<PropertyKey, unknown>).nullable
113+
// if (nullableOrigin !== undefined) {
114+
// const existing = (nullTypeObject[originsFlag] as Record<PropertyKey, unknown>) || {}
115+
// nullTypeObject[originsFlag] = { ...existing, nullable: nullableOrigin }
116+
// }
117+
//
118+
// return nullTypeObject
119+
// }
120+
121+
const buildNullTypeWithOrigins = (
122+
valueWithoutNullable: Record<PropertyKey, unknown>,
123+
originsFlag: PropertyKey,
124+
syntheticTitleFlag: PropertyKey
125+
): Record<PropertyKey, unknown> => {
126+
const nullTypeObject: Record<PropertyKey, unknown> = {
127+
[JSON_SCHEMA_PROPERTY_TYPE]: JSON_SCHEMA_NODE_TYPE_NULL,
128+
}
129+
130+
const valueOrigins = valueWithoutNullable[originsFlag] as Record<PropertyKey, unknown> | undefined
131+
const origins: Record<PropertyKey, unknown> = {}
132+
133+
if (valueWithoutNullable.title) {
134+
nullTypeObject.title = valueWithoutNullable.title
135+
if (valueOrigins?.title !== undefined) {
136+
origins.title = valueOrigins.title
137+
}
138+
}
139+
140+
if (valueOrigins?.nullable !== undefined) {
141+
142+
// origins.nullable = valueOrigins.nullable
143+
144+
const getOriginParent = (item: unknown) => ({
145+
value: JSON_SCHEMA_PROPERTY_TYPE,
146+
parent: (item as any)?.parent,
147+
})
148+
149+
origins[JSON_SCHEMA_PROPERTY_TYPE] = Array.isArray(valueOrigins.nullable)
150+
? valueOrigins.nullable.map(getOriginParent)
151+
: getOriginParent(valueOrigins.nullable)
152+
}
153+
154+
if (Object.keys(origins).length > 0) {
155+
nullTypeObject[originsFlag] = origins
156+
}
157+
158+
return nullTypeObject
159+
}
160+
161+
// Adapter to transform JSON Schemas used in OpenAPI 3.0 schemas to JSON Schemas used in OpenAPI 3.1 for comparison
162+
const jsonSchemaOas30to31Adapter: AdapterResolver = (value, reference, ctx) => {
163+
if (!isObject(value) || !isObject(reference)) {
164+
return value
165+
}
166+
167+
if (value[JSON_SCHEMA_PROPERTY_NULLABLE] !== true) {
168+
return value
169+
}
170+
171+
if (!hasNullType(reference)) {
172+
return value
173+
}
174+
175+
const originsFlag: PropertyKey = ctx.options.originsFlag
176+
// @ts-ignore
177+
const syntheticTitleFlag: PropertyKey = ctx.options.syntheticTitleFlag
178+
179+
return ctx.transformer(value, 'nullable-to-anyof', (current) => {
180+
const { [JSON_SCHEMA_PROPERTY_NULLABLE]: _nullable, ...valueWithoutNullable } = current as Record<PropertyKey, unknown>
181+
182+
const nullTypeObject = buildNullTypeWithOrigins(valueWithoutNullable, originsFlag, syntheticTitleFlag)
183+
184+
const anyOfArray = [valueWithoutNullable, nullTypeObject]
185+
186+
const result: Record<PropertyKey, unknown> = { [JSON_SCHEMA_PROPERTY_ANY_OF]: anyOfArray }
187+
188+
setOrigins(result, JSON_SCHEMA_PROPERTY_ANY_OF, originsFlag, ctx.valueOrigins)
189+
setOrigins(anyOfArray, 0, originsFlag, ctx.valueOrigins)
190+
setOrigins(anyOfArray, 1, originsFlag, ctx.valueOrigins)
191+
192+
return result
193+
})
194+
}
195+
65196
export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions): CompareRules => {
66197
//todo find better way to remove this 'version specific' copy-paste with normalize
67198
const jsonSchemaVersion: JsonSchemaSpecVersion = options.version === SPEC_TYPE_OPEN_API_30 ? SPEC_TYPE_JSON_SCHEMA_04 : SPEC_TYPE_JSON_SCHEMA_07
68199

69200
const schemaRules = jsonSchemaRules({
70201
additionalRules: {
71202
adapter: [
203+
...(options.version === SPEC_TYPE_OPEN_API_31 ? [jsonSchemaOas30to31Adapter] : []),
72204
jsonSchemaAdapter(openApiJsonSchemaAnyFactory(options.version)),
73205
],
74206
descriptionParamCalculator: schemaParamsCalculator,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
openapi: 3.1.0
2+
info:
3+
title: Swagger Petstore - OpenAPI 3.1
4+
version: 1.0.12
5+
paths:
6+
/path1:
7+
post:
8+
responses:
9+
'200':
10+
$ref: '#/components/responses/response200'
11+
description: response description override
12+
components:
13+
responses:
14+
response200:
15+
description: response description from components
16+
content:
17+
application/json:
18+
schema:
19+
type: object
20+
properties:
21+
id:
22+
type: integer
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
openapi: 3.0.4
2+
info:
3+
title: Swagger Petstore - OpenAPI 3.1
4+
version: 1.0.12
5+
paths:
6+
/path1:
7+
post:
8+
responses:
9+
'200':
10+
$ref: '#/components/responses/response200'
11+
components:
12+
responses:
13+
response200:
14+
description: response description from components
15+
content:
16+
application/json:
17+
schema:
18+
type: object
19+
properties:
20+
id:
21+
type: integer
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
openapi: 3.1.0
2+
info:
3+
title: test
4+
version: 0.1.0
5+
paths:
6+
/path1:
7+
get:
8+
requestBody:
9+
content:
10+
'application/json':
11+
schema:
12+
$ref: '#/components/schemas/MySchema'
13+
components:
14+
schemas:
15+
MySchema:
16+
anyOf:
17+
- type: string
18+
- type: 'null'
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
openapi: 3.0.4
2+
info:
3+
title: test
4+
version: 0.1.0
5+
paths:
6+
/path1:
7+
get:
8+
requestBody:
9+
content:
10+
'application/json':
11+
schema:
12+
$ref: '#/components/schemas/MySchema'
13+
components:
14+
schemas:
15+
MySchema:
16+
type: string
17+
nullable: true
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
openapi: 3.1.0
2+
info:
3+
title: test
4+
version: 0.1.0
5+
paths:
6+
/path1:
7+
post:
8+
responses:
9+
'200':
10+
description: OK
11+
content:
12+
'application/json':
13+
schema: {}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
openapi: 3.0.4
2+
info:
3+
title: test
4+
version: 0.1.0
5+
paths:
6+
/path1:
7+
post:
8+
responses:
9+
'200':
10+
description: OK
11+
content:
12+
'application/json':
13+
schema: {}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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+
anyOf:
15+
- type: string
16+
- type: 'null'

0 commit comments

Comments
 (0)