Skip to content
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@
"update-lock-file": "update-lock-file @netcracker"
},
"dependencies": {
"@netcracker/qubership-apihub-api-unifier": "2.4.0",
"@netcracker/qubership-apihub-api-unifier": "feature-oas-30-to-31-comparison",
"@netcracker/qubership-apihub-json-crawl": "1.0.4",
"fast-equals": "4.0.3"
},
"devDependencies": {
"@netcracker/qubership-apihub-compatibility-suites": "2.3.0",
"@netcracker/qubership-apihub-compatibility-suites": "dev",
"@netcracker/qubership-apihub-graphapi": "1.0.8",
"@netcracker/qubership-apihub-npm-gitflow": "3.1.0",
"@types/jest": "29.5.11",
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
128 changes: 123 additions & 5 deletions src/openapi/openapi3.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,30 @@ import {
transformCompareRules,
} from '../core'
import type { OpenApi3SchemaRulesOptions } from './openapi3.types'
import { CompareRules } from '../types'
import { AdapterContext, AdapterResolver, CompareRules } from '../types'
import {
ChainItem,
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 +73,120 @@ 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 = isObject(nullTypeObject[originsFlag]) ? nullTypeObject[originsFlag] as Record<PropertyKey, unknown> : {}

const nullable = inputOrigins && inputOrigins.nullable as ChainItem[]
if (nullable) {
const getOriginParent = (item: ChainItem | undefined) => ({
value: JSON_SCHEMA_PROPERTY_TYPE,
parent: item?.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) => {
if (!isObject(current)) {
return current
}
const {
[JSON_SCHEMA_PROPERTY_NULLABLE]: _nullable,
...valueWithoutNullable
} = current

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 +201,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 +210,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"
}
}
}
}
}
}
}
}
}
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": {}
}
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"openapi": "3.0.4",
"info": {
"title": "test",
"version": "0.1.0"
},
"paths": {
"/path1": {
"post": {
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
}
}
}
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"
}
]
}
}
}
}
Loading
Loading