diff --git a/package.json b/package.json index 96404b8..adbf57d 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "update-lock-file": "update-lock-file @netcracker" }, "dependencies": { - "@netcracker/qubership-apihub-api-unifier": "dev", - "@netcracker/qubership-apihub-json-crawl": "1.0.4", + "@netcracker/qubership-apihub-api-unifier": "feature-support-oas-extension-changes-classification", + "@netcracker/qubership-apihub-json-crawl": "feature-support-oas-extension-changes-classification", "fast-equals": "4.0.3" }, "devDependencies": { diff --git a/src/core/description.ts b/src/core/description.ts index 3652529..7180c50 100644 --- a/src/core/description.ts +++ b/src/core/description.ts @@ -41,30 +41,29 @@ const resolveParamCalculator = (ctx: CompareContext | undefined): DiffTemplatePa return resolveParamCalculator(ctx.parentContext) } -export const calculateDefaultDiffDescription = (diff: Diff) => { - let declarationPaths: JsonPath[] +export const getDeclarationPathsForDiff = (diff: Diff): JsonPath[] => { switch (diff.action) { case DiffAction.add: - declarationPaths = [...diff.afterDeclarationPaths] - break + return [...diff.afterDeclarationPaths] case DiffAction.remove: - declarationPaths = [...diff.beforeDeclarationPaths] - break + return [...diff.beforeDeclarationPaths] case DiffAction.replace: if (diff.afterDeclarationPaths) { - declarationPaths = [...diff.afterDeclarationPaths] + return [...diff.afterDeclarationPaths] } else { - declarationPaths = [...diff.beforeDeclarationPaths] + return [...diff.beforeDeclarationPaths] } - break case DiffAction.rename: if (diff.afterDeclarationPaths) { - declarationPaths = [...diff.afterDeclarationPaths] + return [...diff.afterDeclarationPaths] } else { - declarationPaths = [...diff.beforeDeclarationPaths] + return [...diff.beforeDeclarationPaths] } - break } +} + +export const calculateDefaultDiffDescription = (diff: Diff) => { + const declarationPaths = getDeclarationPathsForDiff(diff) const paths = declarationPaths.map(path => `'${path.join('.')}'`).join(', ') if (diff.scope) { return `[${DIFF_ACTION_TO_ACTION_MAP[diff.action]}] ${paths} in ${diff.scope}` diff --git a/src/openapi/openapi3.compare.rules.ts b/src/openapi/openapi3.compare.rules.ts new file mode 100644 index 0000000..a3850e8 --- /dev/null +++ b/src/openapi/openapi3.compare.rules.ts @@ -0,0 +1,73 @@ +import { allAnnotation } from '../core' +import { DIFF_ACTION_TO_ACTION_MAP, DIFF_ACTION_TO_PREPOSITION_MAP, getDeclarationPathsForDiff } from '../core/description' +import { CompareRules, Diff } from '../types' +import { JsonPath } from '@netcracker/qubership-apihub-json-crawl' + + +const calculateOasExtensionDiffDescription = (diff: Diff) => { + const declarationPaths = getDeclarationPathsForDiff(diff) + + if (declarationPaths.length === 0) { + return '' + } + + // Process paths to extract extension and remaining parts + const splitPaths = declarationPaths.map(path => splitPathAtExtension(path)) + + // Use the first path's extension path (assuming all have the same extension path) + const extensionPath = splitPaths[0].extensionPath + const action = DIFF_ACTION_TO_ACTION_MAP[diff.action] + const preposition = DIFF_ACTION_TO_PREPOSITION_MAP[diff.action] + + // Collect all unique remaining paths, filtering out undefined values + const placePaths = splitPaths + .map(splitPath => splitPath.remainingPath) + .filter((path): path is string => path !== undefined) + + const place = placePaths.length > 0 ? placePaths.join(', ') : 'root' + return `[${action}] extension '${extensionPath}' ${preposition} ${place}` +} + +const splitPathAtExtension = (path: JsonPath): { extensionPath: string, remainingPath: string | undefined } => { + // Find the extension name (starts with 'x-') in the path + let extensionIndex = -1 + for (let i = 0; i < path.length; i++) { + const pathElement = path[i] + if (typeof pathElement === 'string' && pathElement.startsWith('x-')) { + extensionIndex = i + break + } + } + + if (extensionIndex === -1) { + // No extension found, return the whole path as extension path + return { extensionPath: path.join('.'), remainingPath: undefined } + } + + // Build extension path: includes extension name and any nested properties after it + const extensionParts = path.slice(extensionIndex) + const extensionPath = extensionParts.join('.') + + // Build remaining path: everything before the extension name + const remainingParts = path.slice(0, extensionIndex) + const remainingPath = remainingParts.length > 0 ? remainingParts.join('.') : undefined + + return { extensionPath, remainingPath } +} + +export const openApiSpecificationExtensionRules = { + '/^': { + 'x-': { + $: allAnnotation, + description: calculateOasExtensionDiffDescription, + '/*': { + $: allAnnotation, + description: calculateOasExtensionDiffDescription, + }, + '/**': { + $: allAnnotation, + description: calculateOasExtensionDiffDescription, + }, + }, + } +} as CompareRules diff --git a/src/openapi/openapi3.rules.ts b/src/openapi/openapi3.rules.ts index 667c943..9bcbcdd 100644 --- a/src/openapi/openapi3.rules.ts +++ b/src/openapi/openapi3.rules.ts @@ -69,6 +69,7 @@ import { contentParamsCalculator } from './openapi3.description.content' import { examplesParamsCalculator } from './openapi3.description.examples' import { headerParamsCalculator } from './openapi3.description.header' import { encodingParamsCalculator } from './openapi3.description.encoding' +import { openApiSpecificationExtensionRules } from './openapi3.compare.rules' const documentAnnotationRule: CompareRules = { $: allAnnotation } const operationAnnotationRule: CompareRules = { $: allAnnotation } @@ -99,14 +100,24 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { mapping: deepEqualsUniqueItemsArrayMappingResolver, '/*': { ignoreKeyDifference: true }, }, + ...openApiSpecificationExtensionRules, }, }, + ...openApiSpecificationExtensionRules, }, '/**': { $: allAnnotation, }, } + const externalDocumentationRules: CompareRules = { + $: allAnnotation, + ...openApiSpecificationExtensionRules, + '/*': { + $: allAnnotation, + }, + } + const examplesRules: CompareRules = { $: allAnnotation, '/*': { @@ -133,6 +144,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { description: diffDescription(resolveExamplesDescriptionTemplates()), } }, + ...openApiSpecificationExtensionRules, }, '/**': { $: allAnnotation }, } @@ -193,6 +205,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { $: allBreaking, description: diffDescription(resolveParameterDescriptionTemplates('delimited style')) }, + ...openApiSpecificationExtensionRules, }, } @@ -243,6 +256,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { $: allUnclassified, description: diffDescription(resolveHeaderDescriptionTemplates('delimited style')), }, + ...openApiSpecificationExtensionRules, }, } @@ -268,6 +282,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { $: [nonBreaking, breaking, breaking], description: diffDescription(resolveEncodingDescriptionTemplates()) }, + ...openApiSpecificationExtensionRules, }, } @@ -304,6 +319,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { $: allBreaking, ...isResponseSchema(path) ? responseSchemaRules : requestSchemaRules, }), + ...openApiSpecificationExtensionRules, }, } @@ -321,26 +337,23 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { $: [breaking, nonBreaking, breakingIfAfterTrue], description: diffDescription(resolveRequestDescriptionTemplates('required status')) }, + ...openApiSpecificationExtensionRules, } - const responsesRules: CompareRules = { - $: [nonBreaking, breaking, breaking], - [START_NEW_COMPARE_SCOPE_RULE]: COMPARE_SCOPE_RESPONSE, - mapping: apihubCaseInsensitiveKeyMappingResolver, - '/*': { - $: [nonBreaking, breaking, (ctx) => nonBreakingIf(ctx.before.key.toString().toLocaleLowerCase() === ctx.after.key.toString().toLocaleLowerCase())], - description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] response '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}'`), - descriptionParamCalculator: responseParamsCalculator, - '/content': contentRules, - '/description': { - $: allAnnotation, - description: diffDescription([ - `[{{${TEMPLATE_PARAM_ACTION}}}] description {{${TEMPLATE_PARAM_PREPOSITION}}} '{{${TEMPLATE_PARAM_COMPONENT_PATH}}}'`, - `[{{${TEMPLATE_PARAM_ACTION}}}] description {{${TEMPLATE_PARAM_PREPOSITION}}} response '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}'` - ]), - }, - '/headers': headersRules, + const responseRules: CompareRules = { + $: [nonBreaking, breaking, (ctx) => nonBreakingIf(ctx.before.key.toString().toLocaleLowerCase() === ctx.after.key.toString().toLocaleLowerCase())], + description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] response '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}'`), + descriptionParamCalculator: responseParamsCalculator, + '/content': contentRules, + '/description': { + $: allAnnotation, + description: diffDescription([ + `[{{${TEMPLATE_PARAM_ACTION}}}] description {{${TEMPLATE_PARAM_PREPOSITION}}} '{{${TEMPLATE_PARAM_COMPONENT_PATH}}}'`, + `[{{${TEMPLATE_PARAM_ACTION}}}] description {{${TEMPLATE_PARAM_PREPOSITION}}} response '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}'` + ]), }, + '/headers': headersRules, + ...openApiSpecificationExtensionRules, } const operationRule: CompareRules = { @@ -350,18 +363,21 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { //no support? }, }, - '/externalDocs': { - $: allAnnotation, - '/*': { $: allAnnotation }, - }, '/deprecated': { $: allDeprecated }, + '/externalDocs': externalDocumentationRules, '/parameters': { $: [nonBreaking, apihubParametersRemovalClassifyRule, breaking], mapping: paramMappingResolver(2), ...parametersRules, }, '/requestBody': requestBodiesRules, - '/responses': responsesRules, + '/responses': { + $: [nonBreaking, breaking, breaking], + [START_NEW_COMPARE_SCOPE_RULE]: COMPARE_SCOPE_RESPONSE, + mapping: apihubCaseInsensitiveKeyMappingResolver, + ...openApiSpecificationExtensionRules, + '/*': responseRules, + }, '/security': { $: operationSecurityClassifyRule, '/*': { @@ -385,9 +401,28 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { [IGNORE_DIFFERENCE_IN_KEYS_RULE]: true, }, }, + ...openApiSpecificationExtensionRules, '/*': operationAnnotationRule, } + const oAuthFlowObjectRules: CompareRules = { + $: [breaking, nonBreaking, breaking], + ...openApiSpecificationExtensionRules, + } + + const oAuthFlowsObjectRules: CompareRules = { + $: [breaking, nonBreaking, breaking], + ...openApiSpecificationExtensionRules, + '/*': oAuthFlowObjectRules, + } + + const tagObjectCompareRules: CompareRules = { + $: allAnnotation, + '/externalDocs': externalDocumentationRules, + ...openApiSpecificationExtensionRules, + '/*': { $: allAnnotation }, + } + const componentsRule: CompareRules = { $: allNonBreaking, [START_NEW_COMPARE_SCOPE_RULE]: COMPARE_SCOPE_COMPONENTS, @@ -395,7 +430,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { '/headers': headersRules, '/parameters': { $: [nonBreaking, breaking, breaking], - '/*': parametersRules, + ...parametersRules, }, '/requestBodies': { $: [nonBreaking, breaking, breaking], @@ -403,7 +438,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { }, '/responses': { $: [nonBreaking, breaking, breaking], - '/*': responsesRules, + '/*': responseRules, }, '/schemas': { $: [nonBreaking, breaking, breaking], @@ -418,20 +453,30 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { $: [breaking, nonBreaking, breaking], '/bearerFormat': { $: allAnnotation }, '/description': { $: allAnnotation }, - '/flows': { $: [breaking, nonBreaking, breaking] }, + '/flows': oAuthFlowsObjectRules, '/in': { $: [breaking, nonBreaking, breaking] }, '/name': { $: [breaking, nonBreaking, breaking] }, '/openIdConnectUrl': { $: allAnnotation }, '/scheme': { $: [breaking, nonBreaking, breaking] }, '/type': { $: [breaking, nonBreaking, breaking] }, + ...openApiSpecificationExtensionRules, }, }, + ...openApiSpecificationExtensionRules, } return { + ...openApiSpecificationExtensionRules, '/openapi': documentAnnotationRule, '/info': { ...documentAnnotationRule, + ...openApiSpecificationExtensionRules, + '/contact': { + ...openApiSpecificationExtensionRules, + }, + '/license': { + ...openApiSpecificationExtensionRules, + }, '/**': documentAnnotationRule, }, '/servers': serversRules, @@ -448,17 +493,22 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { ...parametersRules, }, '/servers': serversRules, - '/summary': { $: allAnnotation }, + '/summary': { $: allAnnotation }, + ...openApiSpecificationExtensionRules, '/*': operationRule, }, + ...openApiSpecificationExtensionRules, }, '/components': componentsRule, '/security': { $: globalSecurityClassifyRule, '/*': { $: globalSecurityItemClassifyRule }, }, - '/tags': { $: allAnnotation }, - '/externalDocs': { $: allAnnotation }, + '/tags': { + $: allAnnotation, + '/*': tagObjectCompareRules, + }, + '/externalDocs': externalDocumentationRules, } } diff --git a/src/openapi/openapi3.schema.ts b/src/openapi/openapi3.schema.ts index f619d71..8c6e609 100644 --- a/src/openapi/openapi3.schema.ts +++ b/src/openapi/openapi3.schema.ts @@ -28,6 +28,7 @@ import { SPEC_TYPE_OPEN_API_31, } from '@netcracker/qubership-apihub-api-unifier' import { schemaParamsCalculator } from './openapi3.description.schema' +import { openApiSpecificationExtensionRules } from './openapi3.compare.rules' const SPEC_TYPE_TO_VERSION: Record = { [SPEC_TYPE_OPEN_API_30]: '3.0.0', @@ -98,12 +99,16 @@ export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions): Compare $: allAnnotation, description: diffDescription(resolveSchemaDescriptionTemplates('url of externalDocs')) }, + ...openApiSpecificationExtensionRules, '/*': { $: allAnnotation, description: diffDescription(resolveSchemaDescriptionTemplates('externalDocs')) }, }, - '/xml': {}, + '/xml': { + ...openApiSpecificationExtensionRules, + }, + ...openApiSpecificationExtensionRules, }, version: jsonSchemaVersion, }) diff --git a/test/bugs.test.ts b/test/bugs.test.ts index ae02bd6..66e746b 100644 --- a/test/bugs.test.ts +++ b/test/bugs.test.ts @@ -30,6 +30,8 @@ import spearedParamsAfter from './helper/resources/speared-parameters/after.json import wildcardContentSchemaMediaTypeCombinedWithSpecificMediaTypeBefore from './helper/resources/wildcard-content-schema-media-type-combined-with-specific-media-type/before.json' import wildcardContentSchemaMediaTypeCombinedWithSpecificMediaTypeAfter from './helper/resources/wildcard-content-schema-media-type-combined-with-specific-media-type/after.json' +import parameterReuseOnPathItemAndOperationLevel from './helper/resources/parameter-reuse-on-path-item-and-operation-level/specification.json' + import { diffsMatcher } from './helper/matchers' import { TEST_DIFF_FLAG, TEST_ORIGINS_FLAG } from './helper' import { JSON_SCHEMA_NODE_SYNTHETIC_TYPE_NOTHING } from '@netcracker/qubership-apihub-api-unifier' @@ -105,14 +107,14 @@ describe('Real Data', () => { afterDeclarationPaths: [['components', 'schemas', 'DictionaryItem', 'x-entity']], afterValue: 'DictionaryItem', action: DiffAction.add, - type: unclassified, + type: annotation, scope: 'response', }), expect.objectContaining({ afterDeclarationPaths: [['components', 'schemas', 'DictionaryItem', 'x-entity']], afterValue: 'DictionaryItem', action: DiffAction.add, - type: unclassified, + type: annotation, scope: 'components', }), expect.objectContaining({ @@ -227,4 +229,12 @@ describe('Real Data', () => { }), ])) }) + + // TODO: fix + it.skip('should not detect any changes - parameter reuse on path item and operation level', () => { + const before: any = parameterReuseOnPathItemAndOperationLevel + const after: any = parameterReuseOnPathItemAndOperationLevel + const { diffs } = apiDiff(before, after, OPTIONS) + expect(diffs).toBeEmpty() + }) }) diff --git a/test/helper/resources/openapi-specification-extensions/base.json b/test/helper/resources/openapi-specification-extensions/base.json new file mode 100644 index 0000000..23d8867 --- /dev/null +++ b/test/helper/resources/openapi-specification-extensions/base.json @@ -0,0 +1,9 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + } +} \ No newline at end of file diff --git a/test/helper/resources/parameter-reuse-on-path-item-and-operation-level/specification.json b/test/helper/resources/parameter-reuse-on-path-item-and-operation-level/specification.json new file mode 100644 index 0000000..ce0f8eb --- /dev/null +++ b/test/helper/resources/parameter-reuse-on-path-item-and-operation-level/specification.json @@ -0,0 +1,40 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/test/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/sharedParameter" + } + ], + "get": { + "parameters": [ + { + "$ref": "#/components/parameters/sharedParameter" + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + } + }, + "components": { + "parameters": { + "sharedParameter": { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/test/openapi.specificationExtensions.test.ts b/test/openapi.specificationExtensions.test.ts new file mode 100644 index 0000000..239762a --- /dev/null +++ b/test/openapi.specificationExtensions.test.ts @@ -0,0 +1,438 @@ +import { TEST_DIFF_FLAG, TEST_ORIGINS_FLAG } from './helper' +import { CompareOptions } from '../src/types' +import { apiDiff } from '../src/api' +import { DiffAction, annotation } from '../src/core/constants' +import { diffsMatcher } from './helper/matchers' +import { JsonPath } from '@netcracker/qubership-apihub-json-crawl' +import base from './helper/resources/openapi-specification-extensions/base.json' + +const OPTIONS: CompareOptions = { + originsFlag: TEST_ORIGINS_FLAG, + metaKey: TEST_DIFF_FLAG, + validate: true, + unify: true, + liftCombiners: true, + allowNotValidSyntheticChanges: true, +} + +// Helper function to deep clone an object using JSON serialization +function clone(obj: any): any { + return JSON.parse(JSON.stringify(obj)) +} + +// Helper function to set a value at a specific path in an object +// creates a new object or array if some parts of the path are missing +function setValueAtPath(obj: any, path: JsonPath, value: any): void { + if (path.length === 0) return + + let current = obj + for (let i = 0; i < path.length - 1; i++) { + const key = path[i] + const nextKey = path[i + 1] + if (!(key in current)) { + current[key] = typeof nextKey === 'number' ? [] : {} + } + current = current[key] + } + if (value !== undefined) { + current[path[path.length - 1]] = value + } +} + +// Helper function to prepare before and after specifications +function prepareSpecsForComparison(extensionPath: JsonPath, beforeValue?: any, afterValue?: any): { before: any, after: any } { + const before = clone(base) + const after = clone(base) + + setValueAtPath(before, extensionPath, beforeValue) + setValueAtPath(after, extensionPath, afterValue) + + return { before, after } +} + +const serverObjectPaths: JsonPath[] = [ + ['servers', 0], + ['paths', '/somePath', 'servers', 0], + ['paths', '/somePath', 'get', 'servers', 0], +] + +const operationObjectPaths: JsonPath[] = [ + ['paths', '/somePath', 'get'], +] + +const tagObjectPaths: JsonPath[] = [ + ['tags', 0], +] + +const responseObjectPaths: JsonPath[] = [ + ['components', 'responses', 'someResponse'], + ['paths', '/somePath', 'get', 'responses', '200'], +] + +const requestBodyObjectPaths: JsonPath[] = [ + ['paths', '/somePath', 'get', 'requestBody'], + ['components', 'requestBodies', 'someRequestBody'], +] + +const parameterObjectPaths: JsonPath[] = [ + ['components', 'parameters', 'someParameter'], + ['paths', '/somePath', 'parameters', 0], + ['paths', '/somePath', 'get', 'parameters', 0], +] + +const contentEncodingHeaderSuffix = ['content', 'application/json', 'encoding', 'someProperty', 'headers', 'someHeader'] + +const headerObjectPaths: JsonPath[] = [ + ['components', 'headers', 'someHeader'], + ['components', 'responses', 'someResponse', 'headers', 'someHeader'], + ['paths', '/somePath', 'get', 'responses', '200', 'headers', 'someHeader'], + // 'content' not supported for Parameter Object in classification rules yet + //...parameterObjectPaths.map(path => [...path, ...contentEncodingHeaderSuffix]), + ...requestBodyObjectPaths.map(path => [...path, ...contentEncodingHeaderSuffix]), + ...responseObjectPaths.map(path => [...path, ...contentEncodingHeaderSuffix]) +] + +const headerObjectPathsWithRecursionFirstLevel: JsonPath[] = [ + ...headerObjectPaths, + // 'content' not supported for Header Object in classification rules yet + // ...headerObjectPaths.map(path => [...path, ...contentEncodingHeaderSuffix]) +] + +const mediaTypeObjectPaths: JsonPath[] = [ + // 'content' not supported for Parameter Object in classification rules yet + //...parameterObjectPaths.map(path => [...path, 'content', 'application/json']), + // 'content' not supported for Header Object in classification rules yet + //...headerObjectPathsWithRecursionFirstLevel.map(path => [...path, 'content', 'application/json']), + ...requestBodyObjectPaths.map(path => [...path, 'content', 'application/json']), + ...responseObjectPaths.map(path => [...path, 'content', 'application/json']), +] + +const encodingObjectPaths: JsonPath[] = [ + ...mediaTypeObjectPaths.map(path => [...path, 'encoding', 'someProperty']), +] + +const schemaObjectPaths: JsonPath[] = [ + ['components', 'schemas', 'someSchema'], + ...parameterObjectPaths.map(path => [...path, 'schema']), + ...headerObjectPathsWithRecursionFirstLevel.map(path => [...path, 'schema']), + ...mediaTypeObjectPaths.map(path => [...path, 'schema']), +] + +const schemaInSchemaPathSuffixes: JsonPath[] = [ + ['items'], + ['properties', 'someProperty'], + ['allOf', 0], + ['oneOf', 0], + ['anyOf', 0], + ['not'], + ['definitions', 'someSchema'], + ['additionalProperties'], + //['additionalItems'], // additionalItems not supported (removed) by api-unifier now + // add addition places for OAS 3.1, like 'patternProperties', $defs, etc. +] + +const schemaObjectPathsWithRecursionFirstLevel: JsonPath[] = [ + ...schemaObjectPaths, + ...schemaObjectPaths.flatMap(basePath => + schemaInSchemaPathSuffixes.map(suffix => [...basePath, ...suffix]) + ) +] + +const externalDocumentationObjectPaths: JsonPath[] = [ + ['externalDocs'], + ...operationObjectPaths.map(path => [...path, 'externalDocs']), + ...tagObjectPaths.map(path => [...path, 'externalDocs']), + ...schemaObjectPathsWithRecursionFirstLevel.map(path => [...path, 'externalDocs']), +] + +const xmlObjectPaths: JsonPath[] = [ + ...schemaObjectPathsWithRecursionFirstLevel.map(path => [...path, 'xml']), +] + +const linkObjectPaths: JsonPath[] = [ + // Link Object classification rules are not implemented yet + //['components', 'links', 'someLink'], + //...responseObjectPaths.map(path => [...path, 'links', 'someLink']), +] + +const callbackObjectPaths: JsonPath[] = [ + // Callback Object classification rules are not implemented yet + //['components', 'callbacks', 'someCallback'], + //...operationObjectPaths.map(path => [...path, 'callbacks', 'someCallback']), +] + +const pathItemObjectPaths: JsonPath[] = [ + ['paths', '/somePath'], + ...callbackObjectPaths.map(path => [...path, 'someExpression']), + // ['components', 'pathItems', 'somePathItem'], // support path items in components for OAS 3.1 +] + +const securitySchemeObjectPaths: JsonPath[] = [ + ['components', 'securitySchemes', 'oauth2'], +] + +const oAuthFlowsObjectPaths: JsonPath[] = [ + ['components', 'securitySchemes', 'oauth2', 'flows'], +] + +const oAuthFlowObjectPaths: JsonPath[] = [ + ['components', 'securitySchemes', 'oauth2', 'flows', 'implicit'], + ['components', 'securitySchemes', 'oauth2', 'flows', 'password'], + ['components', 'securitySchemes', 'oauth2', 'flows', 'clientCredentials'], + ['components', 'securitySchemes', 'oauth2', 'flows', 'authorizationCode'], +] + +const exampleObjectPaths: JsonPath[] = [ + ['components', 'examples', 'someExample'], + ...parameterObjectPaths.map(path => [...path, 'examples', 'someExample']), + ...headerObjectPathsWithRecursionFirstLevel.map(path => [...path, 'examples', 'someExample']), + ...mediaTypeObjectPaths.map(path => [...path, 'examples', 'someExample']), +] + +// Paths where OpenAPI specification extensions can be added +const specificationExtensionObjectPaths: JsonPath[] = [ + + // OpenAPI Object + [], + + // Info Object and its nested objects + ['info'], + ['info', 'contact'], + ['info', 'license'], + + // Components Object + ['components'], + + // Server Object + ...serverObjectPaths, + + // Server variables + ...serverObjectPaths.map(path => [...path, 'variables', 'someVariable']), + + // Paths Object + ['paths'], + + // Path Item Object + ...pathItemObjectPaths, + + // Operation Object + ...operationObjectPaths, + + // External Documentation Object + ...externalDocumentationObjectPaths, + + // Parameter Object + ...parameterObjectPaths, + + // Request Body Object + ...requestBodyObjectPaths, + + // Media Type Object + ...mediaTypeObjectPaths, + + // Encoding Object + ...encodingObjectPaths, + + // Responses Object + ['paths', '/somePath', 'get', 'responses'], + + // Response Object + ...responseObjectPaths, + + // Callback Object + ...callbackObjectPaths, + + // Example Object + ...exampleObjectPaths, + + // Link Object + ...linkObjectPaths, + + //Header Object + ...headerObjectPathsWithRecursionFirstLevel, + + // Tag Object + ...tagObjectPaths, + + // Schema Object + ...schemaObjectPathsWithRecursionFirstLevel, + + // XML Object + ...xmlObjectPaths, + + // Security Scheme Object + ...securitySchemeObjectPaths, + + // OAuth Flows Object + ...oAuthFlowsObjectPaths, + + // OAuth Flow Object + ...oAuthFlowObjectPaths, +] + +// Common extension name used in all tests +const extensionName = 'x-custom-extension' + +describe('OpenAPI specification extensions changes classification', () => { + + describe('Template check', () => { + it('should not detect any changes in a base specification with no extensions', () => { + const { before, after } = prepareSpecsForComparison([], undefined, undefined) + const { diffs } = apiDiff(before, after, OPTIONS) + expect(diffs).toEqual(diffsMatcher([])) + }) + }) + + //const testPaths: JsonPath[] = [['paths']] // use for debugging specific case + //testPaths.forEach(path => { + specificationExtensionObjectPaths.forEach(path => { + const pathDescription = path.length > 0 ? path.join('.') : '[]' + const fullExtensionPath = [...path, extensionName] + + describe(`Extensions in '${pathDescription}'`, () => { + + const expectedType = annotation + + it(`add specification extension with primitive value`, () => { + const { before, after } = prepareSpecsForComparison(fullExtensionPath, undefined, 'primitive value') + + const { diffs } = apiDiff(before, after, OPTIONS) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + afterDeclarationPaths: [fullExtensionPath], + action: DiffAction.add, + type: expectedType, + afterValue: 'primitive value', + }) + ])) + }) + + it(`add specification extension with complex value`, () => { + const { before, after } = prepareSpecsForComparison(fullExtensionPath, undefined, { key: 'value' }) + + const { diffs } = apiDiff(before, after, OPTIONS) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + afterDeclarationPaths: [fullExtensionPath], + action: DiffAction.add, + type: expectedType, + afterValue: { key: 'value' }, + }) + ])) + }) + + it(`remove specification extension with primitive value`, () => { + const { before, after } = prepareSpecsForComparison(fullExtensionPath, 'primitive value', undefined) + + const { diffs } = apiDiff(before, after, OPTIONS) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [fullExtensionPath], + action: DiffAction.remove, + type: expectedType, + beforeValue: 'primitive value', + }) + ])) + }) + + it(`remove specification extension with complex value`, () => { + const { before, after } = prepareSpecsForComparison(fullExtensionPath, { key: 'value' }, undefined) + + const { diffs } = apiDiff(before, after, OPTIONS) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [fullExtensionPath], + action: DiffAction.remove, + type: expectedType, + beforeValue: { key: 'value' }, + }) + ])) + }) + + it(`change specification extension with primitive value`, () => { + const { before, after } = prepareSpecsForComparison(fullExtensionPath, 'original value', 'changed value') + + const { diffs } = apiDiff(before, after, OPTIONS) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [fullExtensionPath], + afterDeclarationPaths: [fullExtensionPath], + action: DiffAction.replace, + type: expectedType, + beforeValue: 'original value', + afterValue: 'changed value', + }) + ])) + }) + + it(`add property to complex value of specification extension`, () => { + const { before, after } = prepareSpecsForComparison(fullExtensionPath, { nested: {} }, { nested: {property: 'value'} }) + + const { diffs } = apiDiff(before, after, OPTIONS) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + afterDeclarationPaths: [[...fullExtensionPath, 'nested', 'property']], + action: DiffAction.add, + type: expectedType, + afterValue: 'value', + }) + ])) + }) + + it(`delete property from complex value of specification extension`, () => { + const { before, after } = prepareSpecsForComparison(fullExtensionPath, { nested: {property: 'value'} }, { nested: {} }) + + const { diffs } = apiDiff(before, after, OPTIONS) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [[...fullExtensionPath, 'nested', 'property']], + action: DiffAction.remove, + type: expectedType, + beforeValue: 'value', + }) + ])) + }) + + it(`change property in complex value of specification extension`, () => { + const { before, after } = prepareSpecsForComparison(fullExtensionPath, { nested: {property: 'original'} }, { nested: {property: 'modified'} }) + + const { diffs } = apiDiff(before, after, OPTIONS) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [[...fullExtensionPath, 'nested', 'property']], + afterDeclarationPaths: [[...fullExtensionPath, 'nested', 'property']], + action: DiffAction.replace, + type: expectedType, + beforeValue: 'original', + afterValue: 'modified', + }) + ])) + }) + + it(`change specification extension from simple to complex value`, () => { + const { before, after } = prepareSpecsForComparison (fullExtensionPath, 'simple value', { nested: 'modified' }) + + const { diffs } = apiDiff(before, after, OPTIONS) + + expect(diffs).toEqual(diffsMatcher([ + expect.objectContaining({ + beforeDeclarationPaths: [fullExtensionPath], + afterDeclarationPaths: [fullExtensionPath], + action: DiffAction.replace, + type: expectedType, + beforeValue: 'simple value', + afterValue: { nested: 'modified' }, + }) + ])) + }) + }) + }) +})