Skip to content
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"update-lock-file": "update-lock-file @netcracker"
},
"dependencies": {
"@netcracker/qubership-apihub-api-unifier": "dev",
"@netcracker/qubership-apihub-api-unifier": "feature-oas-30-to-31-comparison",
"@netcracker/qubership-apihub-json-crawl": "1.0.4",
"fast-equals": "4.0.3"
},
Expand Down
17 changes: 15 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,24 @@ function areSpecTypesCompatible(beforeType: SpecType, afterType: SpecType): bool
if (beforeType === afterType) {
return true
}

// Allow comparison between different OpenAPI versions
return isOpenApiSpecVersion(beforeType) && isOpenApiSpecVersion(afterType)
}

function selectEngineSpecType(beforeType: SpecType, afterType: SpecType): SpecType {
// For OpenAPI version comparisons, use the higher version
if (isOpenApiSpecVersion(beforeType) && isOpenApiSpecVersion(afterType)) {
if (beforeType === SPEC_TYPE_OPEN_API_31 || afterType === SPEC_TYPE_OPEN_API_31) {
return SPEC_TYPE_OPEN_API_31
}
return SPEC_TYPE_OPEN_API_30
}

// For same spec types or other compatible types, use the before type
return beforeType
}

export const COMPARE_ENGINES_MAP: Record<SpecType, CompareEngine> = {
[SPEC_TYPE_JSON_SCHEMA_04]: compareJsonSchema(SPEC_TYPE_JSON_SCHEMA_04),
[SPEC_TYPE_JSON_SCHEMA_06]: compareJsonSchema(SPEC_TYPE_JSON_SCHEMA_06),
Expand All @@ -48,7 +61,7 @@ export function apiDiff(before: unknown, after: unknown, options: CompareOptions
if (!areSpecTypesCompatible(beforeSpec.type, afterSpec.type)) {
throw new Error(`Specification cannot be different. Got ${beforeSpec.type} and ${afterSpec.type}`)
}
const engine = COMPARE_ENGINES_MAP[beforeSpec.type]
const engine = COMPARE_ENGINES_MAP[selectEngineSpecType(beforeSpec.type, afterSpec.type)]
return engine(before, after, {
mode: COMPARE_MODE_DEFAULT,
normalizedResult: DEFAULT_NORMALIZED_RESULT,
Expand Down
124 changes: 119 additions & 5 deletions src/openapi/openapi3.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,29 @@ import {
transformCompareRules,
} from '../core'
import type { OpenApi3SchemaRulesOptions } from './openapi3.types'
import { CompareRules } from '../types'
import { AdapterContext, AdapterResolver, CompareRules } from '../types'
import {
cleanOrigins,
JSON_SCHEMA_NODE_TYPE_NULL,
JSON_SCHEMA_PROPERTY_ANY_OF,
JSON_SCHEMA_PROPERTY_NULLABLE,
JSON_SCHEMA_PROPERTY_ONE_OF,
JSON_SCHEMA_PROPERTY_TITLE,
JSON_SCHEMA_PROPERTY_TYPE,
JsonSchemaSpecVersion,
normalize,
type OpenApiSpecVersion,
OriginsMetaRecord,
setOrigins,
SPEC_TYPE_JSON_SCHEMA_04,
SPEC_TYPE_JSON_SCHEMA_07,
SPEC_TYPE_OPEN_API_30,
SPEC_TYPE_OPEN_API_31,
} from '@netcracker/qubership-apihub-api-unifier'
import { schemaParamsCalculator } from './openapi3.description.schema'
import { isArray, isObject } from '../utils'

const NULL_TYPE_COMBINERS = [JSON_SCHEMA_PROPERTY_ANY_OF, JSON_SCHEMA_PROPERTY_ONE_OF] as const
const SPEC_TYPE_TO_VERSION: Record<OpenApiSpecVersion, string> = {
[SPEC_TYPE_OPEN_API_30]: '3.0.0',
[SPEC_TYPE_OPEN_API_31]: '3.1.0',
Expand Down Expand Up @@ -62,13 +72,117 @@ const openApiJsonSchemaAnyFactory: (version: OpenApiSpecVersion) => NativeAnySch
return normalizedSpec.components.schemas.empty as Record<PropertyKey, unknown>
}

const hasNullType = (schema: unknown): boolean => {
if (!isObject(schema)) {
return false
}

const type = schema[JSON_SCHEMA_PROPERTY_TYPE]
if (type === JSON_SCHEMA_NODE_TYPE_NULL) {
return true
}

if (isArray(type) && type.includes(JSON_SCHEMA_NODE_TYPE_NULL)) {
return true
}

return NULL_TYPE_COMBINERS.some((combiner) => {
const variants = schema[combiner]
return isArray(variants) && variants.some(hasNullType)
})
}

const buildNullTypeWithOrigins = (
valueWithoutNullable: Record<PropertyKey, unknown>,
context: AdapterContext<unknown>,
factory: NativeAnySchemaFactory,
): Record<PropertyKey, unknown> => {
const { options, valueOrigins } = context
const { originsFlag, syntheticTitleFlag } = options

const nullTypeObject = factory(
{ [JSON_SCHEMA_PROPERTY_TYPE]: JSON_SCHEMA_NODE_TYPE_NULL },
valueOrigins,
options,
)

const inputOrigins = valueWithoutNullable[originsFlag] as Record<PropertyKey, unknown> | undefined
const nullTypeOrigins = nullTypeObject[originsFlag] as Record<PropertyKey, unknown> | undefined ?? {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's better to check type explicitly with isObject

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not necessary to cast type if you checked it explicitly. If it doesn't work, maybe you have to extract it to fixed const before checking.

It increases code size, but it is more type safe


const nullable = inputOrigins && inputOrigins.nullable
if (nullable) {
const getOriginParent = (item: unknown) => ({
value: JSON_SCHEMA_PROPERTY_TYPE,
parent: (item as any)?.parent,
})

nullTypeOrigins[JSON_SCHEMA_PROPERTY_TYPE] = isArray(nullable)
? nullable.map(getOriginParent)
: getOriginParent(nullable)
}

const title = valueWithoutNullable[JSON_SCHEMA_PROPERTY_TITLE]
if (title) {
nullTypeObject[JSON_SCHEMA_PROPERTY_TITLE] = title

const titleOrigins = inputOrigins && inputOrigins[JSON_SCHEMA_PROPERTY_TITLE]
if (titleOrigins) {
nullTypeOrigins[JSON_SCHEMA_PROPERTY_TITLE] = titleOrigins
}
}

if (syntheticTitleFlag && valueWithoutNullable[syntheticTitleFlag]) {
nullTypeObject[syntheticTitleFlag] = true
}

return nullTypeObject
}

const jsonSchemaOas30to31Adapter: (factory: NativeAnySchemaFactory) => AdapterResolver = (factory) => (value, reference, valueContext) => {
if (!isObject(value) || !isObject(reference)) {
return value
}

if (value[JSON_SCHEMA_PROPERTY_NULLABLE] !== true) {
return value
}

if (!hasNullType(reference)) {
return value
}

const { originsFlag } = valueContext.options

return valueContext.transformer(value, 'nullable-to-anyof', (current) => {
const {
[JSON_SCHEMA_PROPERTY_NULLABLE]: _nullable,
...valueWithoutNullable
} = current as Record<PropertyKey, unknown>

const nullTypeObject = buildNullTypeWithOrigins(valueWithoutNullable, valueContext, factory)

cleanOrigins(valueWithoutNullable, JSON_SCHEMA_PROPERTY_NULLABLE, originsFlag)

const anyOfArray = [valueWithoutNullable, nullTypeObject]

const result: Record<PropertyKey, unknown> = { [JSON_SCHEMA_PROPERTY_ANY_OF]: anyOfArray }

setOrigins(result, JSON_SCHEMA_PROPERTY_ANY_OF, originsFlag, valueContext.valueOrigins)
setOrigins(anyOfArray, 0, originsFlag, valueContext.valueOrigins)
setOrigins(anyOfArray, 1, originsFlag, valueContext.valueOrigins)

return result
})
}

export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions): CompareRules => {
//todo find better way to remove this 'version specific' copy-paste with normalize
const jsonSchemaVersion: JsonSchemaSpecVersion = options.version === SPEC_TYPE_OPEN_API_30 ? SPEC_TYPE_JSON_SCHEMA_04 : SPEC_TYPE_JSON_SCHEMA_07

const schemaRules = jsonSchemaRules({
additionalRules: {
adapter: [
...(options.version === SPEC_TYPE_OPEN_API_31 ? [jsonSchemaOas30to31Adapter(openApiJsonSchemaAnyFactory(options.version))] : []),
jsonSchemaAdapter(openApiJsonSchemaAnyFactory(options.version)),
],
descriptionParamCalculator: schemaParamsCalculator,
Expand All @@ -83,7 +197,7 @@ export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions): Compare
nonBreaking,
({ after }) => breakingIf(!!after.value),
],
description: diffDescription(resolveSchemaDescriptionTemplates('nullable status'))
description: diffDescription(resolveSchemaDescriptionTemplates('nullable status')),
},
'/discriminator': { $: allUnclassified },
'/example': { $: allAnnotation, description: diffDescription(resolveSchemaDescriptionTemplates('example')) },
Expand All @@ -92,15 +206,15 @@ export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions): Compare
description: diffDescription(resolveSchemaDescriptionTemplates('externalDocs')),
'/description': {
$: allAnnotation,
description: diffDescription(resolveSchemaDescriptionTemplates('description of externalDocs'))
description: diffDescription(resolveSchemaDescriptionTemplates('description of externalDocs')),
},
'/url': {
$: allAnnotation,
description: diffDescription(resolveSchemaDescriptionTemplates('url of externalDocs'))
description: diffDescription(resolveSchemaDescriptionTemplates('url of externalDocs')),
},
'/*': {
$: allAnnotation,
description: diffDescription(resolveSchemaDescriptionTemplates('externalDocs'))
description: diffDescription(resolveSchemaDescriptionTemplates('externalDocs')),
},
},
'/xml': {},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"openapi": "3.1.0",
"info": {
"title": "Swagger Petstore - OpenAPI 3.1",
"version": "1.0.12"
},
"paths": {
"/path1": {
"post": {
"responses": {
"200": {
"$ref": "#/components/responses/response200",
"description": "response description override"
}
}
}
}
},
"components": {
"responses": {
"response200": {
"description": "response description from components",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "integer"
}
}
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"openapi": "3.0.4",
"info": {
"title": "Swagger Petstore - OpenAPI 3.1",
"version": "1.0.12"
},
"paths": {
"/path1": {
"post": {
"responses": {
"200": {
"$ref": "#/components/responses/response200"
}
}
}
}
},
"components": {
"responses": {
"response200": {
"description": "response description from components",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "integer"
}
}
}
}
}
}
}
}
}
39 changes: 39 additions & 0 deletions test/helper/resources/30-to-31/combiner-to-other-type/after.json
Original file line number Diff line number Diff line change
@@ -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": {
"$ref": "#/components/schemas/MySchema"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"MySchema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
}
}
}
33 changes: 33 additions & 0 deletions test/helper/resources/30-to-31/combiner-to-other-type/before.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"openapi": "3.0.4",
"info": {
"title": "test",
"version": "0.1.0"
},
"paths": {
"/path1": {
"get": {
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MySchema"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"MySchema": {
"type": "string",
"nullable": true
}
}
}
}
23 changes: 23 additions & 0 deletions test/helper/resources/30-to-31/empty-schema/after.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"openapi": "3.1.0",
"info": {
"title": "test",
"version": "0.1.0"
},
"paths": {
"/path1": {
"post": {
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
}
}
}
Loading