Skip to content

Commit 20679bd

Browse files
authored
feat: nullable in OAS 3.0 is equivalent to union with null type in 3.1 (#42)
1 parent 8f4508a commit 20679bd

File tree

16 files changed

+644
-9
lines changed

16 files changed

+644
-9
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@
2727
"update-lock-file": "update-lock-file @netcracker"
2828
},
2929
"dependencies": {
30-
"@netcracker/qubership-apihub-api-unifier": "2.4.0",
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
},
3434
"devDependencies": {
35-
"@netcracker/qubership-apihub-compatibility-suites": "2.3.0",
35+
"@netcracker/qubership-apihub-compatibility-suites": "dev",
3636
"@netcracker/qubership-apihub-graphapi": "1.0.8",
3737
"@netcracker/qubership-apihub-npm-gitflow": "3.1.0",
3838
"@types/jest": "29.5.11",

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: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,30 @@ import {
1616
transformCompareRules,
1717
} from '../core'
1818
import type { OpenApi3SchemaRulesOptions } from './openapi3.types'
19-
import { CompareRules } from '../types'
19+
import { AdapterContext, AdapterResolver, CompareRules } from '../types'
2020
import {
21+
ChainItem,
22+
cleanOrigins,
23+
JSON_SCHEMA_NODE_TYPE_NULL,
24+
JSON_SCHEMA_PROPERTY_ANY_OF,
25+
JSON_SCHEMA_PROPERTY_NULLABLE,
26+
JSON_SCHEMA_PROPERTY_ONE_OF,
27+
JSON_SCHEMA_PROPERTY_TITLE,
28+
JSON_SCHEMA_PROPERTY_TYPE,
2129
JsonSchemaSpecVersion,
2230
normalize,
2331
type OpenApiSpecVersion,
2432
OriginsMetaRecord,
33+
setOrigins,
2534
SPEC_TYPE_JSON_SCHEMA_04,
2635
SPEC_TYPE_JSON_SCHEMA_07,
2736
SPEC_TYPE_OPEN_API_30,
2837
SPEC_TYPE_OPEN_API_31,
2938
} from '@netcracker/qubership-apihub-api-unifier'
3039
import { schemaParamsCalculator } from './openapi3.description.schema'
40+
import { isArray, isObject } from '../utils'
3141

42+
const NULL_TYPE_COMBINERS = [JSON_SCHEMA_PROPERTY_ANY_OF, JSON_SCHEMA_PROPERTY_ONE_OF] as const
3243
const SPEC_TYPE_TO_VERSION: Record<OpenApiSpecVersion, string> = {
3344
[SPEC_TYPE_OPEN_API_30]: '3.0.0',
3445
[SPEC_TYPE_OPEN_API_31]: '3.1.0',
@@ -62,13 +73,120 @@ const openApiJsonSchemaAnyFactory: (version: OpenApiSpecVersion) => NativeAnySch
6273
return normalizedSpec.components.schemas.empty as Record<PropertyKey, unknown>
6374
}
6475

76+
const hasNullType = (schema: unknown): boolean => {
77+
if (!isObject(schema)) {
78+
return false
79+
}
80+
81+
const type = schema[JSON_SCHEMA_PROPERTY_TYPE]
82+
if (type === JSON_SCHEMA_NODE_TYPE_NULL) {
83+
return true
84+
}
85+
86+
if (isArray(type) && type.includes(JSON_SCHEMA_NODE_TYPE_NULL)) {
87+
return true
88+
}
89+
90+
return NULL_TYPE_COMBINERS.some((combiner) => {
91+
const variants = schema[combiner]
92+
return isArray(variants) && variants.some(hasNullType)
93+
})
94+
}
95+
96+
const buildNullTypeWithOrigins = (
97+
valueWithoutNullable: Record<PropertyKey, unknown>,
98+
context: AdapterContext<unknown>,
99+
factory: NativeAnySchemaFactory,
100+
): Record<PropertyKey, unknown> => {
101+
const { options, valueOrigins } = context
102+
const { originsFlag, syntheticTitleFlag } = options
103+
104+
const nullTypeObject = factory(
105+
{ [JSON_SCHEMA_PROPERTY_TYPE]: JSON_SCHEMA_NODE_TYPE_NULL },
106+
valueOrigins,
107+
options,
108+
)
109+
110+
const inputOrigins = valueWithoutNullable[originsFlag] as Record<PropertyKey, unknown> | undefined
111+
const nullTypeOrigins = isObject(nullTypeObject[originsFlag]) ? nullTypeObject[originsFlag] as Record<PropertyKey, unknown> : {}
112+
113+
const nullable = inputOrigins && inputOrigins.nullable as ChainItem[]
114+
if (nullable) {
115+
const getOriginParent = (item: ChainItem | undefined) => ({
116+
value: JSON_SCHEMA_PROPERTY_TYPE,
117+
parent: item?.parent,
118+
})
119+
120+
nullTypeOrigins[JSON_SCHEMA_PROPERTY_TYPE] = isArray(nullable)
121+
? (nullable).map(getOriginParent)
122+
: getOriginParent(nullable)
123+
}
124+
125+
const title = valueWithoutNullable[JSON_SCHEMA_PROPERTY_TITLE]
126+
if (title) {
127+
nullTypeObject[JSON_SCHEMA_PROPERTY_TITLE] = title
128+
129+
const titleOrigins = inputOrigins && inputOrigins[JSON_SCHEMA_PROPERTY_TITLE]
130+
if (titleOrigins) {
131+
nullTypeOrigins[JSON_SCHEMA_PROPERTY_TITLE] = titleOrigins
132+
}
133+
}
134+
135+
if (syntheticTitleFlag && valueWithoutNullable[syntheticTitleFlag]) {
136+
nullTypeObject[syntheticTitleFlag] = true
137+
}
138+
139+
return nullTypeObject
140+
}
141+
142+
const jsonSchemaOas30to31Adapter: (factory: NativeAnySchemaFactory) => AdapterResolver = (factory) => (value, reference, valueContext) => {
143+
if (!isObject(value) || !isObject(reference)) {
144+
return value
145+
}
146+
147+
if (value[JSON_SCHEMA_PROPERTY_NULLABLE] !== true) {
148+
return value
149+
}
150+
151+
if (!hasNullType(reference)) {
152+
return value
153+
}
154+
155+
const { originsFlag } = valueContext.options
156+
157+
return valueContext.transformer(value, 'nullable-to-anyof', (current) => {
158+
if (!isObject(current)) {
159+
return current
160+
}
161+
const {
162+
[JSON_SCHEMA_PROPERTY_NULLABLE]: _nullable,
163+
...valueWithoutNullable
164+
} = current
165+
166+
const nullTypeObject = buildNullTypeWithOrigins(valueWithoutNullable, valueContext, factory)
167+
168+
cleanOrigins(valueWithoutNullable, JSON_SCHEMA_PROPERTY_NULLABLE, originsFlag)
169+
170+
const anyOfArray = [valueWithoutNullable, nullTypeObject]
171+
172+
const result: Record<PropertyKey, unknown> = { [JSON_SCHEMA_PROPERTY_ANY_OF]: anyOfArray }
173+
174+
setOrigins(result, JSON_SCHEMA_PROPERTY_ANY_OF, originsFlag, valueContext.valueOrigins)
175+
setOrigins(anyOfArray, 0, originsFlag, valueContext.valueOrigins)
176+
setOrigins(anyOfArray, 1, originsFlag, valueContext.valueOrigins)
177+
178+
return result
179+
})
180+
}
181+
65182
export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions): CompareRules => {
66183
//todo find better way to remove this 'version specific' copy-paste with normalize
67184
const jsonSchemaVersion: JsonSchemaSpecVersion = options.version === SPEC_TYPE_OPEN_API_30 ? SPEC_TYPE_JSON_SCHEMA_04 : SPEC_TYPE_JSON_SCHEMA_07
68185

69186
const schemaRules = jsonSchemaRules({
70187
additionalRules: {
71188
adapter: [
189+
...(options.version === SPEC_TYPE_OPEN_API_31 ? [jsonSchemaOas30to31Adapter(openApiJsonSchemaAnyFactory(options.version))] : []),
72190
jsonSchemaAdapter(openApiJsonSchemaAnyFactory(options.version)),
73191
],
74192
descriptionParamCalculator: schemaParamsCalculator,
@@ -83,7 +201,7 @@ export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions): Compare
83201
nonBreaking,
84202
({ after }) => breakingIf(!!after.value),
85203
],
86-
description: diffDescription(resolveSchemaDescriptionTemplates('nullable status'))
204+
description: diffDescription(resolveSchemaDescriptionTemplates('nullable status')),
87205
},
88206
'/discriminator': { $: allUnclassified },
89207
'/example': { $: allAnnotation, description: diffDescription(resolveSchemaDescriptionTemplates('example')) },
@@ -92,15 +210,15 @@ export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions): Compare
92210
description: diffDescription(resolveSchemaDescriptionTemplates('externalDocs')),
93211
'/description': {
94212
$: allAnnotation,
95-
description: diffDescription(resolveSchemaDescriptionTemplates('description of externalDocs'))
213+
description: diffDescription(resolveSchemaDescriptionTemplates('description of externalDocs')),
96214
},
97215
'/url': {
98216
$: allAnnotation,
99-
description: diffDescription(resolveSchemaDescriptionTemplates('url of externalDocs'))
217+
description: diffDescription(resolveSchemaDescriptionTemplates('url of externalDocs')),
100218
},
101219
'/*': {
102220
$: allAnnotation,
103-
description: diffDescription(resolveSchemaDescriptionTemplates('externalDocs'))
221+
description: diffDescription(resolveSchemaDescriptionTemplates('externalDocs')),
104222
},
105223
},
106224
'/xml': {},
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "Swagger Petstore - OpenAPI 3.1",
5+
"version": "1.0.12"
6+
},
7+
"paths": {
8+
"/path1": {
9+
"post": {
10+
"responses": {
11+
"200": {
12+
"$ref": "#/components/responses/response200",
13+
"description": "response description override"
14+
}
15+
}
16+
}
17+
}
18+
},
19+
"components": {
20+
"responses": {
21+
"response200": {
22+
"description": "response description from components",
23+
"content": {
24+
"application/json": {
25+
"schema": {
26+
"type": "object",
27+
"properties": {
28+
"id": {
29+
"type": "integer"
30+
}
31+
}
32+
}
33+
}
34+
}
35+
}
36+
}
37+
}
38+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"openapi": "3.0.4",
3+
"info": {
4+
"title": "Swagger Petstore - OpenAPI 3.1",
5+
"version": "1.0.12"
6+
},
7+
"paths": {
8+
"/path1": {
9+
"post": {
10+
"responses": {
11+
"200": {
12+
"$ref": "#/components/responses/response200"
13+
}
14+
}
15+
}
16+
}
17+
},
18+
"components": {
19+
"responses": {
20+
"response200": {
21+
"description": "response description from components",
22+
"content": {
23+
"application/json": {
24+
"schema": {
25+
"type": "object",
26+
"properties": {
27+
"id": {
28+
"type": "integer"
29+
}
30+
}
31+
}
32+
}
33+
}
34+
}
35+
}
36+
}
37+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "test",
5+
"version": "0.1.0"
6+
},
7+
"paths": {
8+
"/path1": {
9+
"post": {
10+
"responses": {
11+
"200": {
12+
"description": "OK",
13+
"content": {
14+
"application/json": {
15+
"schema": {}
16+
}
17+
}
18+
}
19+
}
20+
}
21+
}
22+
}
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"openapi": "3.0.4",
3+
"info": {
4+
"title": "test",
5+
"version": "0.1.0"
6+
},
7+
"paths": {
8+
"/path1": {
9+
"post": {
10+
"responses": {
11+
"200": {
12+
"description": "OK",
13+
"content": {
14+
"application/json": {
15+
"schema": {}
16+
}
17+
}
18+
}
19+
}
20+
}
21+
}
22+
}
23+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "test",
5+
"version": "0.1.0"
6+
},
7+
"paths": {
8+
"/path1": {
9+
"get": {
10+
"responses": {
11+
"200": {
12+
"description": "OK",
13+
"content": {
14+
"application/json": {
15+
"schema": {
16+
"$ref": "#/components/schemas/MySchema"
17+
}
18+
}
19+
}
20+
}
21+
}
22+
}
23+
}
24+
},
25+
"components": {
26+
"schemas": {
27+
"MySchema": {
28+
"anyOf": [
29+
{
30+
"type": "string"
31+
},
32+
{
33+
"type": "null"
34+
}
35+
]
36+
}
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)