├── .gitignore ├── LICENSE ├── README.md ├── biome.json ├── bun.lockb ├── bunfig.toml ├── docs └── index.html ├── package.json ├── rollup.config.js ├── src ├── api.ts ├── asyncapi │ ├── asyncapi.compare.ts │ ├── asyncapi.types.ts │ ├── asyncapi.utils.ts │ ├── asyncapi2.rules.ts │ └── index.ts ├── core │ ├── annotate.ts │ ├── compare.ts │ ├── constants.ts │ ├── diff.ts │ ├── index.ts │ ├── mapping.ts │ └── rules.ts ├── graphapi │ ├── graphapi.annotate.ts │ ├── graphapi.compare.ts │ ├── graphapi.const.ts │ ├── graphapi.rules.ts │ ├── graphapi.schema.ts │ ├── graphapi.transform.ts │ ├── graphapi.utils.ts │ └── index.ts ├── index.ts ├── jsonSchema │ ├── index.ts │ ├── jsonSchema.annotate.ts │ ├── jsonSchema.classify.ts │ ├── jsonSchema.compare.ts │ ├── jsonSchema.consts.ts │ ├── jsonSchema.mapping.ts │ ├── jsonSchema.resolver.ts │ ├── jsonSchema.rules.ts │ ├── jsonSchema.transform.ts │ ├── jsonSchema.types.ts │ └── jsonSchema.utils.ts ├── openapi │ ├── index.ts │ ├── openapi3.annotate.ts │ ├── openapi3.classify.ts │ ├── openapi3.compare.ts │ ├── openapi3.mapping.ts │ ├── openapi3.rules.ts │ ├── openapi3.schema.ts │ ├── openapi3.transform.ts │ ├── openapi3.types.ts │ └── openapi3.utils.ts ├── types │ ├── compare.ts │ ├── index.ts │ └── rules.ts └── utils.ts ├── test ├── annotate.test.ts ├── asyncapi │ └── asyncapi.test.ts ├── graphapi │ ├── arguments.test.ts │ ├── directives.test.ts │ ├── graphschema.test.ts │ └── operation.test.ts ├── helpers │ └── index.ts ├── jsonSchema │ ├── array-schema.test.ts │ ├── combinary-schema.test.ts │ ├── object-schema.test.ts │ ├── schema-with-refs.test.ts │ └── simple-schema.test.ts ├── openapi │ ├── openapi3.test.ts │ ├── operation.test.ts │ ├── parameters.test.ts │ ├── requestBody.test.ts │ └── responses.test.ts ├── resources │ ├── externalref.yaml │ ├── jsonschema.yaml │ ├── petstore.yaml │ ├── schema-after.graphql │ ├── schema-after.yaml │ ├── schema-before.graphql │ └── schema-before.yaml └── rules.test.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .vscode 4 | dist 5 | .vuepress 6 | temp 7 | browser 8 | coverage 9 | .DS_store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Damir Yusipov 2 | 3 | MIT License: 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # api-smart-diff 2 | npm npm npm type definitions GitHub 3 | 4 | This package provides utils to compute the diff between two Json based API documents - [online demo](https://udamir.github.io/api-smart-diff/) 5 | 6 | ## Purpose 7 | - Generate API changelog 8 | - Identify breaking changes 9 | - Ensure API versioning consistency 10 | 11 | ## Supported API specifications 12 | 13 | - [OpenApi 3.0](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md) 14 | - [AsyncApi 2.x](https://v2.asyncapi.com/docs/reference) 15 | - [JsonSchema](https://json-schema.org/draft/2020-12/json-schema-core.html) 16 | - GraphQL via [GraphApi](https://github.com/udamir/graphapi) 17 | - ~~[Swagger 2.0](https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md)~~ 18 | - ~~[AsyncApi 3.x](https://www.asyncapi.com/docs/specifications/)~~ (Roadmap) 19 | - ~~gRPC~~ (Roadmap) 20 | 21 | ## Features 22 | - Generate diff for supported specifications 23 | - Generate merged document with changes in metadata 24 | - Classify all changes as breaking, non-breaking, deprecated and annotation 25 | - Human-readable change description 26 | - Supports custom classification rules 27 | - Supports custom comparison or match rules 28 | - Supports custom transformations 29 | - Supports custom human-readable changes annotation 30 | - Resolves all $ref pointers, including circular 31 | - Typescript syntax support out of the box 32 | - Can be used in nodejs or browser 33 | 34 | ## External $ref 35 | If schema contains an external $ref, you should bundle it via [api-ref-bundler](https://github.com/udamir/api-ref-bundler) first. 36 | 37 | ## Installation 38 | ```SH 39 | npm install api-smart-diff --save 40 | ``` 41 | or 42 | ```SH 43 | yarn add api-smart-diff 44 | ``` 45 | 46 | ## Usage 47 | 48 | ### Nodejs 49 | ```ts 50 | import { apiCompare } from 'api-smart-diff' 51 | 52 | const { diffs, merged } = apiCompare(before, after) 53 | // diff: 54 | // { 55 | // action: "add" | "remove" | "replace" | "rename", 56 | // after: 'value in after', 57 | // before: 'value in before', 58 | // description: 'human-readable description' 59 | // path: ['path, 'in', 'array', 'format'], 60 | // type: "annotation" | "breaking" | "non-breaking" | "unclassified" | "deprecated" 61 | // } 62 | 63 | // merged meta: 64 | // { 65 | // action: "add" | "remove" | "replace" | "rename", 66 | // type: "annotation" | "breaking" | "non-breaking" | "unclassified" | "deprecated", 67 | // replaced: "value in before", 68 | // } 69 | 70 | ``` 71 | 72 | ### Browsers 73 | 74 | A browser version of `api-smart-diff` is also available via CDN: 75 | ```html 76 | 77 | ``` 78 | 79 | Reference `api-smart-diff.min.js` in your HTML and use the global variable `ApiSmartDiff`. 80 | ```HTML 81 | 84 | ``` 85 | 86 | ## Documentation 87 | 88 | Package provides the following public functions: 89 | 90 | `apiCompare (before, after, options?: CompareOptions): { diffs: Diff[], merged: object }` 91 | > Calculates the difference and merge two objects and classify difference in accordinance with before document type 92 | 93 | 94 | ### **apiCompare(before, after, options)** 95 | The apiDiff function calculates the difference between two objects. 96 | 97 | #### *Arguments* 98 | - `before: any` - the origin object 99 | - `after: any` - the object being compared structurally with the origin object\ 100 | - `options: CompareOptions` [optional] - comparison options 101 | 102 | ```ts 103 | export type ComapreOptions = { 104 | rules?: CompareRules // custom rules for compare 105 | 106 | metaKey?: string | symbol // metakey for merge changes 107 | arrayMeta?: boolean // add changes to arrays via metakey 108 | annotateHook?: AnnotateHook // custom format hook 109 | 110 | externalSources?: { // resolved external $ref sources 111 | before?: Record 112 | after?: Record 113 | } 114 | } 115 | ``` 116 | #### *Result* 117 | Function returns object with `diffs` array and `merged` object with metadata 118 | ```ts 119 | type Diff = { 120 | action: "add" | "remove" | "replace" | "rename" 121 | path: Array 122 | description?: string 123 | before?: any 124 | after?: any 125 | type: "breaking" | "non-breaking" | "annotation" | "unclassified" | "deprecated" 126 | } 127 | 128 | type MergeMeta = DiffMeta | MergeArrayMeta 129 | type MergeArrayMeta = { array: Record } 130 | 131 | export type DiffMeta = { 132 | action: "add" | "remove" | "replace" | "rename" 133 | type: "breaking" | "non-breaking" | "annotation" | "unclassified" | "deprecated" 134 | replaced?: any 135 | } 136 | ``` 137 | 138 | #### *Example* 139 | ```ts 140 | const metaKey = Symbol("diff") 141 | const { diffs, merged } = apiCompare(before, after, { metaKey }) 142 | 143 | // do something with diffs or merged object 144 | ``` 145 | 146 | ### **Custom rules** 147 | Custom compare rules can be defined as CrawlRules: 148 | ```ts 149 | import { CrawlRules } from "json-crawl" 150 | 151 | type CompareRules = CrawlRules 152 | 153 | type CompareRule = { 154 | $?: ClassifyRule // classifier for current node 155 | compare?: CompareResolver // compare handler for current node 156 | transform?: CompareTransformResolver[] // transformations before compare/merge 157 | mapping?: MappingResolver // keys mapping rules 158 | annotate?: ChangeAnnotationResolver // resolver for annotation template 159 | } 160 | 161 | // Change classifier 162 | type ClassifyRule = [ 163 | DiffType | (ctx: ComapreContext) => DiffType, // add 164 | DiffType | (ctx: ComapreContext) => DiffType, // remove 165 | DiffType | (ctx: ComapreContext) => DiffType, // replace (rename) 166 | DiffType | (ctx: ComapreContext) => DiffType, // (optional) reversed rule for add 167 | DiffType | (ctx: ComapreContext) => DiffType, // (optional) reversed rule for remove 168 | DiffType | (ctx: ComapreContext) => DiffType // (optional) reversed rule for replace (rename) 169 | ] 170 | 171 | // Compare context 172 | type ComapreContext = { 173 | before: NodeContext // before node context 174 | after: NodeContext // after node context 175 | rules: CompareRules // rules for compared nodes 176 | options: ComapreOptions // compare options 177 | } 178 | 179 | // Node context 180 | type NodeContext = { 181 | path: JsonPath 182 | key: string | number 183 | value: unknown 184 | parent?: unknown 185 | root: unknown 186 | } 187 | 188 | // Custom compare resolver 189 | type CompareResolver = (ctx: ComapreContext) => CompareResult | void 190 | 191 | // Transformation rules 192 | type CompareTransformResolver = (before: T, after: T) => [T, T] 193 | 194 | // Mapping rules 195 | type MappingResolver = ( 196 | before: Record | unknown[], 197 | after: Record | unknown[], 198 | ctx: ComapreContext 199 | ) => MapKeysResult 200 | 201 | type MapKeysResult = { 202 | added: Array 203 | removed: Array 204 | mapped: Record 205 | } 206 | 207 | // Annotation tempalte resolver 208 | type ChangeAnnotationResolver = (diff: Diff, ctx: ComapreContext) => AnnotateTemplate | undefined 209 | 210 | type AnnotateTemplate = { 211 | template: string, 212 | params?: { [key: string]: AnnotateTemplate | string | number | undefined } 213 | } 214 | 215 | ``` 216 | 217 | ## Contributing 218 | When contributing, keep in mind that it is an objective of `api-smart-diff` to have no additional package dependencies. This may change in the future, but for now, no new dependencies. 219 | 220 | Please run the unit tests before submitting your PR: `yarn test`. Hopefully your PR includes additional unit tests to illustrate your change/modification! 221 | 222 | ## License 223 | 224 | MIT 225 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "files": { 4 | "ignore": ["dist/**"] 5 | }, 6 | "organizeImports": { 7 | "enabled": false 8 | }, 9 | "linter": { 10 | "enabled": true, 11 | "rules": { 12 | "recommended": true, 13 | "suspicious": { 14 | "noExplicitAny": "off", 15 | "noAssignInExpressions": "info" 16 | }, 17 | "complexity": { 18 | "noForEach": "off", 19 | "noBannedTypes": "off" 20 | }, 21 | "style": { 22 | "noParameterAssign": "info", 23 | "noNonNullAssertion": "info" 24 | }, 25 | "performance": { 26 | "noDelete": "off" 27 | } 28 | } 29 | }, 30 | "vcs": { 31 | "enabled": true, 32 | "clientKind": "git", 33 | "useIgnoreFile": true, 34 | "defaultBranch": "master" 35 | }, 36 | "formatter": { 37 | "enabled": true, 38 | "formatWithErrors": false, 39 | "indentStyle": "space", 40 | "indentWidth": 2, 41 | "lineEnding": "lf", 42 | "lineWidth": 120, 43 | "ignore": ["package.json"] 44 | }, 45 | "javascript": { 46 | "formatter": { 47 | "quoteStyle": "double", 48 | "trailingCommas": "all", 49 | "semicolons": "asNeeded" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udamir/api-smart-diff/aaed6b3d8c9492e6cb5d291acc0c0f01dec4e379/bun.lockb -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | 3 | # always enable coverage 4 | coverage = true 5 | 6 | # new 7 | coverageReporter = ["text", "lcov"] 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-smart-diff", 3 | "version": "1.0.6", 4 | "description": "Generate the diff between two API specifications (OpenAPI, AsyncAPI, GraphApi, JsonSchema)", 5 | "module": "dist/index.mjs", 6 | "main": "dist/index.cjs", 7 | "browser": "dist/api-smart-diff.min.js", 8 | "typings": "dist/index.d.ts", 9 | "files": [ 10 | "dist" 11 | ], 12 | "exports": { 13 | ".": { 14 | "types": "./dist/index.d.ts", 15 | "import": "./dist/index.mjs", 16 | "require": "./dist/index.cjs" 17 | } 18 | }, 19 | "scripts": { 20 | "prebuild": "rimraf ./dist", 21 | "build": "rollup -c", 22 | "check": "biome check" 23 | }, 24 | "keywords": [ 25 | "jsonschema", 26 | "diff", 27 | "merge", 28 | "compare", 29 | "openapi", 30 | "swagger", 31 | "asyncapi", 32 | "graphql", 33 | "graphapi", 34 | "api" 35 | ], 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/udamir/api-smart-diff" 39 | }, 40 | "author": "Damir Yusipov", 41 | "license": "MIT", 42 | "devDependencies": { 43 | "@rollup/plugin-babel": "^6.0.4", 44 | "@rollup/plugin-commonjs": "^25.0.8", 45 | "@rollup/plugin-json": "^6.1.0", 46 | "@rollup/plugin-node-resolve": "^15.2.3", 47 | "@rollup/plugin-terser": "^0.4.4", 48 | "@rollup/plugin-typescript": "^11.1.6", 49 | "@biomejs/biome": "^1.8.3", 50 | "@types/js-yaml": "^4.0.9", 51 | "fast-json-patch": "^3.1.1", 52 | "gqlapi": "^0.5.1", 53 | "graphql": "^16.9.0", 54 | "js-yaml": "^4.1.0", 55 | "rimraf": "^5.0.8", 56 | "rollup": "^2.79.1", 57 | "rollup-plugin-filesize": "^9.1.2", 58 | "rollup-plugin-progress": "^1.1.2", 59 | "ts-loader": "^8.4.0", 60 | "typescript": "^5.5.3" 61 | }, 62 | "dependencies": { 63 | "allof-merge": "^0.6.6", 64 | "json-crawl": "^0.5.3" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript" 2 | import resolve from "@rollup/plugin-node-resolve" 3 | import commonjs from "@rollup/plugin-commonjs" 4 | import filesize from "rollup-plugin-filesize" 5 | import progress from "rollup-plugin-progress" 6 | import terser from "@rollup/plugin-terser" 7 | import babel from "@rollup/plugin-babel" 8 | import json from "@rollup/plugin-json" 9 | 10 | import pkg from "./package.json" 11 | 12 | const packageName = "ApiSmartDiff" 13 | const inputPath = "./src" 14 | 15 | const preamble = `/*! 16 | * ${pkg.name} v${pkg.version} 17 | * Copyright (C) 2012-${new Date().getFullYear()} ${pkg.author} 18 | * Date: ${new Date().toUTCString()} 19 | */` 20 | 21 | const extensions = [".ts", ".js"] 22 | 23 | const jsPlugins = [ 24 | resolve(), 25 | commonjs({ 26 | include: "node_modules/**", 27 | }), 28 | json(), 29 | progress(), 30 | filesize({ 31 | showGzippedSize: true, 32 | }), 33 | typescript(), 34 | babel({ 35 | babelHelpers: "bundled", 36 | exclude: "node_modules/**", 37 | include: [`${inputPath}/**/*`], 38 | extensions, 39 | }), 40 | terser({ 41 | format: { 42 | preamble, 43 | comments: false, 44 | }, 45 | }), 46 | ] 47 | 48 | function makeConfig(file, format) { 49 | return { 50 | input: `${inputPath}/index.ts`, 51 | output: { 52 | file, 53 | format, 54 | name: packageName, 55 | sourcemap: true, 56 | }, 57 | plugins: jsPlugins, 58 | } 59 | } 60 | 61 | export default [makeConfig(pkg.main, "umd"), makeConfig(pkg.module, "esm"), makeConfig(pkg.browser, "iife")] 62 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import type { ComapreOptions, CompareEngine } from "./types" 2 | import { compareJsonSchema } from "./jsonSchema" 3 | import { compareGraphApi } from "./graphapi" 4 | import { compareAsyncApi } from "./asyncapi" 5 | import { compareOpenApi } from "./openapi" 6 | import { isObject, isString } from "./utils" 7 | 8 | export const discoverCompareEngine = (data: unknown): CompareEngine => { 9 | if (!isObject(data)) { 10 | return compareJsonSchema 11 | } 12 | 13 | if ("openapi" in data && isString(data.openapi) && /3.+/.test(data.openapi)) return compareOpenApi 14 | if ("asyncapi" in data && isString(data.asyncapi) && /2.+/.test(data.asyncapi)) return compareAsyncApi 15 | // if (/2.+/.test(data?.swagger || "")) return swagger2Rules 16 | if ("graphapi" in data && data.graphapi) return compareGraphApi 17 | return compareJsonSchema 18 | } 19 | 20 | // !Deprecated 21 | export const apiMerge = (before: unknown, after: unknown, options: ComapreOptions = {}) => { 22 | const engine = discoverCompareEngine(before) 23 | 24 | const { merged } = engine(before, after, options) 25 | 26 | return merged 27 | } 28 | 29 | // !Deprecated 30 | export const apiDiff = (before: unknown, after: unknown, options: ComapreOptions = {}) => { 31 | const engine = discoverCompareEngine(before) 32 | 33 | const { diffs } = engine(before, after, options) 34 | 35 | return diffs 36 | } 37 | 38 | export const apiCompare = (before: unknown, after: unknown, options: ComapreOptions = {}) => { 39 | const engine = discoverCompareEngine(before) 40 | 41 | return engine(before, after, options) 42 | } 43 | -------------------------------------------------------------------------------- /src/asyncapi/asyncapi.compare.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncApiComapreOptions } from "./asyncapi.types" 2 | import type { CompareResult, SourceContext } from "../types" 3 | import { getAsyncApiVersion } from "./asyncapi.utils" 4 | import { asyncApi2Rules } from "./asyncapi2.rules" 5 | import { compare } from "../core" 6 | 7 | export const compareAsyncApi = ( 8 | before: unknown, 9 | after: unknown, 10 | options: AsyncApiComapreOptions = {}, 11 | context: SourceContext = {}, 12 | ): CompareResult => { 13 | // const { notMergeAllOf } = options 14 | 15 | const version = getAsyncApiVersion(before, after) 16 | 17 | if (version !== "2.x") { 18 | throw new Error(`Unsupported version: ${version}`) 19 | } 20 | 21 | // set default options 22 | const _options = { 23 | ...options, 24 | rules: options.rules ?? asyncApi2Rules(), 25 | // annotateHook: options.annotateHook ?? openApi3AnnotateHook 26 | } 27 | 28 | return compare(before, after, _options, context) 29 | } 30 | -------------------------------------------------------------------------------- /src/asyncapi/asyncapi.types.ts: -------------------------------------------------------------------------------- 1 | import type { ComapreOptions } from "../types" 2 | 3 | export type AsyncApiRulesOptions = { 4 | // version?: "2.x" | "3.x" 5 | notMergeAllOf?: boolean 6 | } 7 | 8 | export type AsyncApiSchemaRulesOptions = AsyncApiRulesOptions & { 9 | response?: boolean 10 | } 11 | 12 | export type AsyncApiComapreOptions = ComapreOptions & AsyncApiRulesOptions 13 | -------------------------------------------------------------------------------- /src/asyncapi/asyncapi.utils.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "../utils" 2 | 3 | export const getAsyncApiVersion = (before: unknown, after: unknown) => { 4 | if (!isObject(before) || !isObject(after) || !("asyncapi" in before) || !("asyncapi" in after)) { 5 | return 6 | } 7 | 8 | const bMajorVersion = String(before.asyncapi).charAt(0) 9 | const aMajorVersion = String(after.asyncapi).charAt(0) 10 | 11 | if (bMajorVersion === aMajorVersion) { 12 | return `${bMajorVersion}.x` 13 | } 14 | 15 | return "" 16 | } 17 | -------------------------------------------------------------------------------- /src/asyncapi/asyncapi2.rules.ts: -------------------------------------------------------------------------------- 1 | import { 2 | reverseClassifyRuleTransformer, 3 | transformComapreRules, 4 | addNonBreaking, 5 | allBreaking, 6 | allNonBreaking, 7 | allUnclassified, 8 | allAnnotation, 9 | } from "../core" 10 | import { combinaryCompareResolver, createFields, createRefsCompareResolver, jsonSchemaRules } from "../jsonSchema" 11 | import type { CompareRules } from "../types" 12 | 13 | export const asyncApi2Rules = (): CompareRules => { 14 | const subSchemaRules = transformComapreRules(jsonSchemaRules(), reverseClassifyRuleTransformer) 15 | const pubSchemaRules = jsonSchemaRules() 16 | const refsCompareResolver = createRefsCompareResolver() 17 | 18 | const correlationIdRules: CompareRules = { 19 | $: addNonBreaking, 20 | "/location": { $: addNonBreaking }, 21 | "/description": { $: allAnnotation }, 22 | } 23 | 24 | const bindingsRule: CompareRules = { 25 | $: allUnclassified, 26 | "/*": { 27 | $: allUnclassified, 28 | compare: refsCompareResolver, 29 | "/*": { $: allUnclassified }, 30 | "/query": () => subSchemaRules, 31 | "/headers": () => pubSchemaRules, 32 | }, 33 | } 34 | 35 | const commonRules: CompareRules = { 36 | transform: [createFields("tags", "traits", "bindings", "examples")], 37 | "/summary": { $: allAnnotation }, 38 | "/tags": { $: allAnnotation }, 39 | "/externalDocs": { $: allAnnotation }, 40 | "/bindings": bindingsRule, 41 | } 42 | 43 | const pubsubTraitsRules: CompareRules = { 44 | $: addNonBreaking, 45 | "/*": { $: addNonBreaking }, 46 | "/operationId": { $: allAnnotation }, 47 | "/description": { $: allAnnotation }, 48 | ...commonRules, 49 | } 50 | 51 | const messageTraitsRules: CompareRules = { 52 | $: addNonBreaking, 53 | "/*": { $: addNonBreaking }, 54 | "/headers": { $: allUnclassified }, 55 | "/correlationId": correlationIdRules, 56 | "/schemaFormat": { $: allBreaking }, 57 | "/contentType": { $: addNonBreaking }, 58 | "/name": { $: allNonBreaking }, 59 | "/title": { $: allNonBreaking }, 60 | "/examples": { $: allAnnotation }, 61 | ...commonRules, 62 | } 63 | 64 | const messageRules = (sub = false): CompareRules => ({ 65 | $: allBreaking, 66 | "/headers": { $: allUnclassified }, 67 | "/correlationId": correlationIdRules, 68 | "/schemaFormat": { $: allBreaking }, 69 | "/contentType": { $: addNonBreaking }, 70 | "/name": { $: allNonBreaking }, 71 | "/title": { $: allAnnotation }, 72 | "/description": { $: allAnnotation }, 73 | "/examples": { $: allAnnotation }, 74 | "/traits": messageTraitsRules, 75 | "/payload": () => ({ 76 | ...(sub ? subSchemaRules : pubSchemaRules), 77 | $: allBreaking, 78 | }), 79 | ...commonRules, 80 | }) 81 | 82 | const pubsubRules = (sub = false): CompareRules => ({ 83 | $: addNonBreaking, 84 | "/operationId": { $: allAnnotation }, 85 | "/description": { $: allAnnotation }, 86 | "/traits": pubsubTraitsRules, 87 | "/message": { 88 | "/oneOf": { 89 | compare: combinaryCompareResolver, 90 | "/*": { 91 | ...messageRules(sub), 92 | $: addNonBreaking, 93 | }, 94 | }, 95 | ...messageRules(sub), 96 | }, 97 | ...commonRules, 98 | }) 99 | 100 | const infoRules: CompareRules = { 101 | $: allAnnotation, 102 | "/version": { $: allAnnotation }, 103 | "/termsOfService": { $: allAnnotation }, 104 | "/license": { 105 | $: allAnnotation, 106 | "/name": { $: allAnnotation }, 107 | "/url": { $: allAnnotation }, 108 | }, 109 | "/title": { $: allAnnotation }, 110 | "/description": { $: allAnnotation }, 111 | "/contact": { 112 | $: allAnnotation, 113 | "/name": { $: allAnnotation }, 114 | "/url": { $: allAnnotation }, 115 | "/email": { $: allAnnotation }, 116 | }, 117 | } 118 | 119 | const serversRules: CompareRules = { 120 | $: allAnnotation, 121 | "/*": { 122 | compare: refsCompareResolver, 123 | transform: [createFields("variables", "bindings", "security")], 124 | $: allAnnotation, 125 | "/url": { $: allAnnotation }, 126 | "/description": { $: allAnnotation }, 127 | "/protocol": { $: allAnnotation }, 128 | "/protocolVersion": { $: allAnnotation }, 129 | "/variables": { 130 | $: allAnnotation, 131 | "/*": { 132 | compare: refsCompareResolver, 133 | $: allAnnotation, 134 | "/enum": { 135 | $: allAnnotation, 136 | "/*": { $: allAnnotation }, 137 | }, 138 | "/default": { $: allAnnotation }, 139 | "/description": { $: allAnnotation }, 140 | "/examples": { $: allAnnotation }, 141 | }, 142 | }, 143 | "/security": { 144 | $: allAnnotation, 145 | "/*": { $: allAnnotation }, 146 | }, 147 | "/bindings": bindingsRule, 148 | }, 149 | } 150 | 151 | const channelRules: CompareRules = { 152 | compare: refsCompareResolver, 153 | transform: [createFields("parameters", "bindings")], 154 | $: addNonBreaking, 155 | "/description": { $: allAnnotation }, 156 | "/bindings": bindingsRule, 157 | "/subscribe": pubsubRules(true), 158 | "/publish": pubsubRules(false), 159 | "/parameters": { 160 | $: allBreaking, 161 | "/*": { 162 | compare: refsCompareResolver, 163 | $: addNonBreaking, 164 | "/description": { $: allAnnotation }, 165 | "/schema": () => ({ 166 | ...pubSchemaRules, 167 | $: allBreaking, 168 | }), 169 | "/location": { $: allBreaking }, 170 | }, 171 | }, 172 | } 173 | 174 | return { 175 | transform: [createFields("channels", "components", "tags", "servers")], 176 | "/asyncapi": { $: allAnnotation }, 177 | "/id": { $: allAnnotation }, 178 | "/defaultContentType": { $: allBreaking }, 179 | "/info": infoRules, 180 | "/servers": serversRules, 181 | "/channels": { 182 | $: addNonBreaking, 183 | "/*": channelRules, 184 | }, 185 | "/components": { 186 | "/*": { $: allAnnotation }, 187 | }, 188 | "/tags": { $: allAnnotation }, 189 | "/externalDocs": { $: allAnnotation }, 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/asyncapi/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./asyncapi.compare" 2 | export * from "./asyncapi2.rules" 3 | export * from "./asyncapi.utils" 4 | export * from "./asyncapi.types" 5 | -------------------------------------------------------------------------------- /src/core/annotate.ts: -------------------------------------------------------------------------------- 1 | import { isExist, isObject, objectKeys } from "../utils" 2 | import type { AnnotateTemplate } from "../types" 3 | 4 | export const annotationTemplate = (template: string, params?: AnnotateTemplate["params"]): AnnotateTemplate => ({ 5 | template, 6 | ...(params ? { params } : {}), 7 | }) 8 | 9 | export const createAnnotation = (annotationTemplate?: AnnotateTemplate, dict: Record = {}): string => { 10 | const findTemplate = (key: string, params: AnnotateTemplate["params"] = {}) => { 11 | const keys = objectKeys(dict).filter((k) => k.startsWith(`${key}_`)) 12 | 13 | let result = key in dict ? dict[key] : undefined 14 | let matchCount = 1 15 | 16 | for (const _key of keys) { 17 | const _params = _key.split("_").slice(1) 18 | // find params 19 | if (!_params.filter((p) => !isExist(params[p]) || params[p] === "").length && _params.length >= matchCount) { 20 | result = dict[_key] 21 | matchCount = _params.length 22 | } 23 | } 24 | 25 | return result 26 | } 27 | 28 | const applyTemplateParams = (name = "", _params: AnnotateTemplate["params"] = {}) => { 29 | const params: Record = {} 30 | 31 | for (const key of objectKeys(_params)) { 32 | const param = _params[key] 33 | params[key] = isObject(param) ? createAnnotation(param as AnnotateTemplate, dict) : (param as string) 34 | } 35 | 36 | let template = findTemplate(name, params) 37 | if (!template) { 38 | return "" 39 | } 40 | 41 | for (const match of [...template.matchAll(/{{(\w+)}}/g)].reverse()) { 42 | if (!(match[1] in params)) { 43 | continue 44 | } 45 | 46 | const index = match.index ?? 0 47 | template = template.substring(0, index) + String(params[match[1]]) + template.substring(index + match[0].length) 48 | } 49 | return template 50 | } 51 | 52 | if (!annotationTemplate) { 53 | return "" 54 | } 55 | 56 | const { template, params } = annotationTemplate 57 | 58 | return applyTemplateParams(template, params) 59 | } 60 | -------------------------------------------------------------------------------- /src/core/compare.ts: -------------------------------------------------------------------------------- 1 | import { getNodeRules, syncCrawl, type SyncCrawlHook } from "json-crawl" 2 | 3 | import type { 4 | ComapreContext, 5 | CompareRule, 6 | ComapreOptions, 7 | CompareResult, 8 | MergeMetaRecord, 9 | SourceContext, 10 | ContextInput, 11 | MergeFactoryResult, 12 | CompareEngine, 13 | } from "../types" 14 | import { getKeyValue, isArray, isNumber, isObject, objectKeys, typeOf } from "../utils" 15 | import { diffFactory, convertDiffToMeta, createMergeMeta } from "./diff" 16 | import { objectMappingResolver, arrayMappingResolver } from "./mapping" 17 | import type { Diff, JsonNode, MergeState } from "../types" 18 | import { DIFF_META_KEY } from "./constants" 19 | 20 | export const createContext = (data: ContextInput, options: ComapreOptions): ComapreContext => { 21 | const { bNode, aNode, aPath, root, akey, bkey, bPath, before, after, rules } = data 22 | const beforePath = bPath.length || bkey !== "#" ? [...bPath, bkey] : [] 23 | const afterPath = aPath.length || akey !== "#" ? [...aPath, akey] : [] 24 | return { 25 | before: { key: bkey, path: beforePath, parent: bNode, value: before, root: root.before["#"] }, 26 | after: { key: akey, path: afterPath, parent: aNode, value: after, root: root.after["#"] }, 27 | rules, 28 | options, 29 | } 30 | } 31 | 32 | export const createChildContext = ( 33 | { before, after, rules, options }: ComapreContext, 34 | bkey: number | string, 35 | akey: number | string, 36 | ): ComapreContext => { 37 | const bValue = getKeyValue(before.value, bkey) 38 | const aValue = getKeyValue(after.value, akey) 39 | return { 40 | before: { path: [...before.path, bkey], key: bkey, value: bValue, parent: before.value, root: before.root }, 41 | after: { path: [...after.path, akey], key: akey, value: aValue, parent: after.value, root: after.root }, 42 | rules: getNodeRules(rules, bkey || akey, bkey ? before.path : after.path, bkey ? bValue : aValue) ?? {}, 43 | options, 44 | } 45 | } 46 | 47 | const mergedResult = (mNode: JsonNode, key: number | string, value: unknown) => { 48 | mNode[key] = value 49 | return { done: true } 50 | } 51 | 52 | const setMergeMeta = (parentMeta: MergeMetaRecord, key: string | number, diff: Diff) => { 53 | parentMeta[key] = convertDiffToMeta(diff) 54 | return diff 55 | } 56 | 57 | const useMergeFactory = (options: ComapreOptions = {}): MergeFactoryResult => { 58 | const _diffs: Diff[] = [] 59 | const { arrayMeta, metaKey = DIFF_META_KEY } = options 60 | 61 | const hook: SyncCrawlHook = (crawlContext) => { 62 | const { rules = {}, state, value, key } = crawlContext 63 | const { transform, compare, mapping, skip } = rules 64 | const { keyMap, parentMeta, bNode, aNode, mNode } = state 65 | 66 | const k = Object.keys(keyMap).pop() 67 | const bkey = key ?? (isArray(bNode) && k ? +k : k) 68 | const akey = keyMap[bkey] 69 | const mkey = isArray(mNode) && isNumber(bkey) ? bkey : akey 70 | 71 | // skip if node was removed 72 | if (skip || !(bkey in keyMap)) { 73 | return mergedResult(mNode, bkey, value) 74 | } 75 | 76 | const bPath = !state.bPath.length && bkey === "#" ? [] : [...state.bPath, bkey] 77 | const aPath = !state.aPath.length && akey === "#" ? [] : [...state.aPath, akey] 78 | 79 | // transform values before comparison 80 | const data: [unknown, unknown] = [value, aNode[akey]] 81 | const [before, after] = !isArray(value) && transform ? transform.reduce((res, t) => t(res[0], res[1]), data) : data 82 | // save transformed values to root nodes 83 | bNode[bkey] = before 84 | aNode[akey] = after 85 | 86 | // compare via custom handler 87 | const ctx = createContext({ ...state, before, after, akey, bkey, rules }, options) 88 | const compared = compare?.(ctx) 89 | if (compared) { 90 | const { diffs, merged, rootMergeMeta } = compared 91 | 92 | _diffs.push(...diffs) 93 | if (rootMergeMeta) { 94 | parentMeta[akey] = rootMergeMeta 95 | } 96 | // TODO: check akey for arrays 97 | return mergedResult(mNode, mkey, merged) 98 | } 99 | 100 | // types are different 101 | if (typeOf(before) !== typeOf(after)) { 102 | _diffs.push(setMergeMeta(parentMeta, akey, diffFactory.replaced(bPath, before, after, ctx))) 103 | return mergedResult(mNode, mkey, after) 104 | } 105 | 106 | // compare objects or arrays 107 | if (isObject(before) && isObject(after)) { 108 | const _nodeDiffs: Diff[] = [] 109 | const merged: any = isArray(before) ? [] : {} 110 | 111 | mNode[mkey] = merged 112 | 113 | const mapKeys = mapping ?? (isArray(before) ? arrayMappingResolver : objectMappingResolver) 114 | const { added, removed, mapped } = mapKeys(before as any, after as any, ctx) 115 | const renamed = isArray(before) ? [] : objectKeys(mapped).filter((key) => key !== mapped[key]) 116 | 117 | _nodeDiffs.push( 118 | ...removed.map((k) => diffFactory.removed([...bPath, k], before[k], createChildContext(ctx, k, ""))), 119 | ) 120 | _nodeDiffs.push( 121 | ...renamed.map((k) => diffFactory.renamed(bPath, k, mapped[k], createChildContext(ctx, k, mapped[k]))), 122 | ) 123 | 124 | _diffs.push(..._nodeDiffs) 125 | 126 | const nodeMeta = createMergeMeta(_nodeDiffs) ?? {} 127 | 128 | const exitHook = () => { 129 | for (const k of added) { 130 | const _key = isArray(merged) ? merged.length : k 131 | const diff = diffFactory.added([...bPath, _key], after[k], createChildContext(ctx, "", k)) 132 | 133 | nodeMeta[_key] = convertDiffToMeta(diff) 134 | merged[_key] = after[k] 135 | 136 | _diffs.push(diff) 137 | } 138 | 139 | if (!Object.keys(nodeMeta).length) { 140 | return 141 | } 142 | 143 | if (isArray(merged) && !arrayMeta) { 144 | parentMeta[akey] = { array: nodeMeta } 145 | } else { 146 | merged[metaKey] = nodeMeta 147 | } 148 | } 149 | 150 | const _state: MergeState = { 151 | ...crawlContext.state, 152 | keyMap: mapped, 153 | aPath, 154 | bPath, 155 | bNode: before, 156 | aNode: after, 157 | parentMeta: nodeMeta, 158 | mNode: merged, 159 | } 160 | 161 | return { value: before, state: _state, exitHook } 162 | } 163 | 164 | if (before !== after) { 165 | _diffs.push(setMergeMeta(parentMeta, akey, diffFactory.replaced(bPath, before, after, ctx))) 166 | } 167 | 168 | //TODO: check for rename 169 | return mergedResult(mNode, mkey, after) 170 | } 171 | 172 | return { diffs: _diffs, hook } 173 | } 174 | 175 | export const compare: CompareEngine = ( 176 | before: unknown, 177 | after: unknown, 178 | options: ComapreOptions = {}, 179 | context: SourceContext = {}, 180 | ): CompareResult => { 181 | // set default context if not assigned 182 | const { jsonPath: _bPath = [], source: bSource = before } = context.before ?? {} 183 | const { jsonPath: _aPath = [], source: aSource = after } = context.after ?? {} 184 | 185 | const root: MergeState["root"] = { 186 | before: { "#": bSource }, 187 | after: { "#": aSource }, 188 | merged: {}, 189 | } 190 | 191 | const bPath = _bPath.slice(0, -1) 192 | const aPath = _aPath.slice(0, -1) 193 | 194 | const bNode = bPath.length ? getKeyValue(bSource, ...bPath) : root.before 195 | const aNode = aPath.length ? getKeyValue(aSource, ...aPath) : root.after 196 | 197 | if (!isObject(bNode) || !isObject(aNode)) { 198 | // TODO 199 | throw new Error("") 200 | } 201 | 202 | const bKey = bPath.length ? _bPath[bPath.length] : "#" 203 | const aKey = aPath.length ? _aPath[aPath.length] : "#" 204 | 205 | const _before = bNode[bKey] 206 | const _after = aNode[aKey] 207 | 208 | bNode[bKey] = before 209 | aNode[aKey] = after 210 | 211 | const { diffs, hook } = useMergeFactory(options) 212 | 213 | const rootState: MergeState = { 214 | aPath, 215 | bPath, 216 | mNode: root.merged, 217 | bNode, 218 | aNode, 219 | keyMap: { [bKey]: aKey }, 220 | parentMeta: {}, 221 | root, 222 | } 223 | 224 | syncCrawl(before, hook, { state: rootState, rules: options.rules }) 225 | 226 | bNode[bKey] = _before 227 | aNode[aKey] = _after 228 | 229 | return { diffs, merged: root.merged[aKey], rootMergeMeta: rootState.parentMeta[aKey] } 230 | } 231 | -------------------------------------------------------------------------------- /src/core/constants.ts: -------------------------------------------------------------------------------- 1 | import type { ClassifyRule } from "../types" 2 | 3 | export const DIFF_META_KEY = "$diff" 4 | 5 | export const DiffAction = { 6 | add: "add", 7 | remove: "remove", 8 | replace: "replace", 9 | rename: "rename", 10 | } as const 11 | 12 | export const ClassifierType = { 13 | breaking: "breaking", 14 | nonBreaking: "non-breaking", 15 | annotation: "annotation", 16 | unclassified: "unclassified", 17 | deprecated: "deprecated", 18 | } as const 19 | 20 | export const { breaking, nonBreaking, unclassified, annotation, deprecated } = ClassifierType 21 | 22 | // predefined classifiers 23 | export const allNonBreaking: ClassifyRule = [nonBreaking, nonBreaking, nonBreaking] 24 | export const allBreaking: ClassifyRule = [breaking, breaking, breaking] 25 | export const onlyAddBreaking: ClassifyRule = [breaking, nonBreaking, nonBreaking] 26 | export const addNonBreaking: ClassifyRule = [nonBreaking, breaking, breaking] 27 | export const allUnclassified: ClassifyRule = [unclassified, unclassified, unclassified] 28 | export const allAnnotation: ClassifyRule = [annotation, annotation, annotation] 29 | export const allDeprecated: ClassifyRule = [deprecated, deprecated, deprecated] 30 | -------------------------------------------------------------------------------- /src/core/diff.ts: -------------------------------------------------------------------------------- 1 | import type { JsonPath } from "json-crawl" 2 | 3 | import type { 4 | DiffFactory, 5 | ComapreContext, 6 | CompareTransformResolver, 7 | Diff, 8 | NodeContext, 9 | DiffMeta, 10 | MergeMetaRecord, 11 | TransformResolver, 12 | } from "../types" 13 | import { DiffAction, allUnclassified, unclassified } from "./constants" 14 | import { getKeyValue, joinPath, isFunc } from "../utils" 15 | 16 | export const createDiff = (diff: Omit, ctx: ComapreContext): Diff => { 17 | const rule = ctx.rules?.$ ?? {} 18 | const _diff: Diff = { ...diff, type: unclassified } 19 | 20 | if (rule) { 21 | const classifier = Array.isArray(rule) ? rule : allUnclassified 22 | 23 | const index = diff.action === "rename" ? 2 : ["add", "remove", "replace"].indexOf(diff.action) 24 | const changeType = classifier[index] 25 | 26 | try { 27 | _diff.type = isFunc(changeType) ? changeType(ctx) : changeType 28 | } catch (error) { 29 | const message = error instanceof Error ? error.message : "" 30 | console.error(`Classification Rule error for node: ${ctx.before.path.join(".")}. ${message}`) 31 | } 32 | } 33 | 34 | const description = ctx.options.annotateHook?.(_diff, ctx) 35 | return { ..._diff, ...(description ? { description } : {}) } 36 | } 37 | 38 | export const diffFactory: DiffFactory = { 39 | added: (path, after, ctx) => createDiff({ path, after, action: DiffAction.add }, ctx), 40 | removed: (path, before, ctx) => createDiff({ path, before, action: DiffAction.remove }, ctx), 41 | replaced: (path, before, after, ctx) => createDiff({ path, before, after, action: DiffAction.replace }, ctx), 42 | renamed: (path, before, after, ctx) => createDiff({ path, before, after, action: DiffAction.rename }, ctx), 43 | } 44 | 45 | export const convertDiffToMeta = (diff: Diff): DiffMeta => { 46 | return { 47 | action: diff.action, 48 | type: diff.type ?? unclassified, 49 | ...(diff.action === "replace" || diff.action === "rename" ? { replaced: diff.before } : {}), 50 | } 51 | } 52 | 53 | export const createMergeMeta = (diffs: Diff[]): MergeMetaRecord => { 54 | const meta: MergeMetaRecord = {} 55 | 56 | for (const diff of diffs) { 57 | const _meta = convertDiffToMeta(diff) 58 | const key = diff.action !== "rename" ? diff.path[diff.path.length - 1] : diff.after 59 | meta[key] = _meta 60 | } 61 | 62 | return meta 63 | } 64 | 65 | export const getParentContext = (ctx: NodeContext, ...path: JsonPath): NodeContext | undefined => { 66 | const _path = joinPath(ctx.path.slice(0, -1), path) 67 | const parentPath = [..._path] 68 | const key = parentPath.pop() 69 | 70 | if (!_path.length || key === undefined) { 71 | return { path: [], key: "", value: ctx.root, root: ctx.root } 72 | } 73 | 74 | const parentValue = getKeyValue(ctx.root, ...parentPath) as Record 75 | const value = parentValue[key] 76 | 77 | if (value === undefined) { 78 | return 79 | } 80 | 81 | return { path: _path, key, value, parent: parentValue, root: ctx.root } 82 | } 83 | 84 | export const compareTransformationFactory = ( 85 | resolver: TransformResolver, 86 | ): CompareTransformResolver => { 87 | return (before, after) => [resolver(before, after), resolver(after, before)] 88 | } 89 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./compare" 2 | export * from "./constants" 3 | export * from "./constants" 4 | export * from "./mapping" 5 | export * from "./annotate" 6 | export * from "./diff" 7 | export * from "./rules" 8 | -------------------------------------------------------------------------------- /src/core/mapping.ts: -------------------------------------------------------------------------------- 1 | import type { MapKeysResult, MappingResolver } from "../types" 2 | 3 | export const arrayMappingResolver: MappingResolver = (before, after) => { 4 | const length = Math.abs(before.length - after.length) 5 | const arr = Array.from({ length: Math.min(before.length, after.length) }, (_, i) => i) 6 | 7 | return { 8 | removed: before.length > after.length ? Array.from({ length }, (_, i) => after.length + i) : [], 9 | added: before.length < after.length ? Array.from({ length }, (_, i) => before.length + i) : [], 10 | mapped: arr.reduce( 11 | (res, i) => { 12 | res[i] = i 13 | return res 14 | }, 15 | {} as Record, 16 | ), 17 | } 18 | } 19 | 20 | export const objectMappingResolver: MappingResolver = (before, after) => { 21 | const result: MapKeysResult = { added: [], removed: [], mapped: {} } 22 | const afterKeys = new Set(Object.keys(after)) 23 | 24 | for (const key of Object.keys(before)) { 25 | if (afterKeys.has(key)) { 26 | result.mapped[key] = key 27 | afterKeys.delete(key) 28 | } else { 29 | result.removed.push(key) 30 | } 31 | } 32 | 33 | for (const key of afterKeys) { 34 | result.added.push(key) 35 | } 36 | 37 | return result 38 | } 39 | 40 | export const caseInsensitiveKeyMappingResolver: MappingResolver = (before, after) => { 41 | const result: MapKeysResult = { added: [], removed: [], mapped: {} } 42 | const afterKeys = new Set(Object.keys(after).map((k) => k.toLocaleLowerCase())) 43 | 44 | for (const _key of Object.keys(before)) { 45 | const key = _key.toLocaleLowerCase() 46 | if (afterKeys.has(key)) { 47 | result.mapped[key] = key 48 | afterKeys.delete(key) 49 | } else { 50 | result.removed.push(key) 51 | } 52 | } 53 | 54 | for (const key of afterKeys) { 55 | result.added.push(key) 56 | } 57 | 58 | return result 59 | } 60 | -------------------------------------------------------------------------------- /src/core/rules.ts: -------------------------------------------------------------------------------- 1 | import { syncClone } from "json-crawl" 2 | 3 | import type { 4 | ClassifyRule, 5 | ClassifyRuleTransformer, 6 | ComapreContext, 7 | CompareRules, 8 | CompareRulesTransformer, 9 | DiffType, 10 | DiffTypeClassifier, 11 | } from "../types" 12 | import { isFunc, isObject, isString } from "../utils" 13 | import { breaking, nonBreaking } from "./constants" 14 | 15 | export const transformComapreRules = (rules: CompareRules, tranformer: CompareRulesTransformer): CompareRules => { 16 | return syncClone(rules, ({ value, key }) => { 17 | if (key && (!isString(key) || !key.startsWith("/"))) { 18 | return 19 | } 20 | if (typeof value === "function") { 21 | return { value: (...args: unknown[]) => transformComapreRules(value(...args), tranformer) } 22 | } 23 | if (!Array.isArray(value) && isObject(value)) { 24 | return { value: tranformer(value as CompareRules) } 25 | } 26 | }) as CompareRules 27 | } 28 | 29 | export const reverseClassifyRuleTransformer: CompareRulesTransformer = (value) => { 30 | // reverse classify rules 31 | if ("$" in value && Array.isArray(value.$)) { 32 | return { ...value, $: reverseClassifyRule(value.$) } 33 | } 34 | 35 | return value 36 | } 37 | 38 | const reverseDiffType = (diffType: DiffType | DiffTypeClassifier): DiffType | DiffTypeClassifier => { 39 | if (typeof diffType === "function") { 40 | return ((ctx: ComapreContext) => reverseDiffType(diffType(ctx))) as DiffTypeClassifier 41 | } 42 | switch (diffType) { 43 | case breaking: 44 | return nonBreaking 45 | case nonBreaking: 46 | return breaking 47 | default: 48 | return diffType 49 | } 50 | } 51 | 52 | export const reverseClassifyRule = ([add, remove, replace, r_add, r_remove, r_replace]: ClassifyRule): ClassifyRule => { 53 | return [r_add ?? reverseDiffType(add), r_remove ?? reverseDiffType(remove), r_replace ?? reverseDiffType(replace)] 54 | } 55 | 56 | export const transformClassifyRule = ( 57 | [add, remove, replace]: ClassifyRule, 58 | transformer: ClassifyRuleTransformer, 59 | ): ClassifyRule => { 60 | return [ 61 | (ctx) => transformer(isFunc(add) ? add(ctx) : add, ctx, "add"), 62 | (ctx) => transformer(isFunc(remove) ? remove(ctx) : remove, ctx, "remove"), 63 | (ctx) => transformer(isFunc(replace) ? replace(ctx) : replace, ctx, "replace"), 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /src/graphapi/graphapi.annotate.ts: -------------------------------------------------------------------------------- 1 | import type { AnnotateHook, ChangeAnnotationResolver } from "../types" 2 | import { createAnnotation, annotationTemplate as t } from "../core" 3 | import { getTarget, jsonSchemaAnnotations } from "../jsonSchema" 4 | import { isArgSchema } from "./graphapi.utils" 5 | 6 | const graphSchemaArgAnnotations = { 7 | add: "[Added] {{text}} to Argument `{{target}}`", 8 | add_target: "[Added] {{text}} to Argument `{{target}}`", 9 | add_target_schema: "[Added] {{text}} to Argument `{{target}}` of property `{{schema}}(...)`", 10 | add_target_directive: "[Added] {{text}} to Argument `{{target}}` of directive `@{{directive}}(...)`", 11 | remove: "[Removed] {{text}} from Argument {{target}}", 12 | remove_target: "[Removed] {{text}} from Argument `{{target}}`", 13 | remove_target_schema: "[Removed] {{text}} from Argument `{{target}}` of property `{{schema}}(...)`", 14 | remove_target_directive: "[Removed] {{text}} from Argument `{{target}}` of directive `@{{directive}}(...)`", 15 | replace: "[Replaced] {{text}} of Argument {{target}}", 16 | replace_target: "[Replaced] {{text}} of Argument `{{target}}`", 17 | replace_target_schema: "[Replaced] {{text}} of Argument `{{target}}` of property `{{schema}}(...)`", 18 | replace_target_directive: "[Replaced] {{text}} of Argument `{{target}}` of directive `@{{directive}}(...)`", 19 | } 20 | 21 | const graphApiAnnotations = { 22 | ...jsonSchemaAnnotations, 23 | 24 | directive: "directive `@{{key}}`", 25 | directive_definition: "difinition for directive `@{{key}}`", 26 | directive_meta: "directive meta `@{{key}}({{meta}})`", 27 | values_annotation: "possible values annotation ({{key}})", 28 | values_status: "possible values {{key}} status", 29 | } 30 | 31 | export const graphApiAnnotateHook: AnnotateHook = (diff, ctx) => { 32 | const annotate = ctx.rules?.annotate 33 | 34 | if (!annotate || (diff.path[0] === "components" && diff.path[1] !== "directives")) { 35 | return "" 36 | } 37 | if (isArgSchema(diff.path)) { 38 | const argsIndex = diff.path.indexOf("args") 39 | const _diff = { ...diff, path: diff.path.slice(argsIndex) } 40 | const schema = getTarget(diff.path.slice(0, argsIndex)) 41 | const directive = diff.path[1] === "directives" ? diff.path[2] : undefined 42 | 43 | const schemaChangeTemplate = annotate(_diff, ctx) 44 | if (!schemaChangeTemplate) { 45 | return "" 46 | } 47 | 48 | const argsTemplate = { ...schemaChangeTemplate, params: { ...schemaChangeTemplate.params, schema, directive } } 49 | return createAnnotation(argsTemplate, { ...graphApiAnnotations, ...graphSchemaArgAnnotations }) 50 | } 51 | 52 | return createAnnotation(annotate(diff, ctx), graphApiAnnotations) 53 | } 54 | 55 | export const valuesAnnotationChange: ChangeAnnotationResolver = ({ path, action }, ctx) => { 56 | const key = path[path.length - 1] 57 | const target = getTarget(path.slice(0, -4)) 58 | // TODO 59 | switch (key) { 60 | case "description": 61 | return t(action, { text: t("values_annotation", { key }), target }) 62 | case "deprecated": 63 | return t(action, { text: t("values_status", { key }), target }) 64 | case "reason": 65 | return t(action, { text: t("values_annotation", { key: "deprecation reason" }), target }) 66 | } 67 | } 68 | 69 | export const parentKeyChangeAnnotation: ChangeAnnotationResolver = ({ path, action }, ctx) => { 70 | const key = path[path.length - 1] 71 | const parentKey = path[path.length - 2] 72 | const target = getTarget(path) 73 | 74 | switch (parentKey) { 75 | case "directives": 76 | return t(action, { text: t("directive", { key, definition: path[0] === "components" ? 1 : undefined }), target }) 77 | case "deprecated": 78 | return t(action, { text: t("reason", { key }), target }) 79 | case "meta": 80 | return t(action, { 81 | text: t("directive", { key: path[path.length - 3], meta: key }), 82 | target: getTarget(path.slice(0, -4)), 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/graphapi/graphapi.compare.ts: -------------------------------------------------------------------------------- 1 | import type { ComapreOptions, CompareResult, SourceContext } from "../types" 2 | import { graphApiAnnotateHook } from "./graphapi.annotate" 3 | import { graphApiRules } from "./graphapi.rules" 4 | import { compare } from "../core" 5 | 6 | export const compareGraphApi = ( 7 | before: unknown, 8 | after: unknown, 9 | options: ComapreOptions = {}, 10 | context: SourceContext = {}, 11 | ): CompareResult => { 12 | // set default options 13 | const _options = { 14 | ...options, 15 | rules: options.rules ?? graphApiRules(), 16 | annotateHook: options.annotateHook ?? graphApiAnnotateHook, 17 | } 18 | 19 | return compare(before, after, _options, context) 20 | } 21 | -------------------------------------------------------------------------------- /src/graphapi/graphapi.const.ts: -------------------------------------------------------------------------------- 1 | export const graphApiComponents = { 2 | ScalarTypeDefinition: "scalars", 3 | ObjectTypeDefinition: "objects", 4 | InterfaceTypeDefinition: "interfaces", 5 | InputObjectTypeDefinition: "inputObjects", 6 | DirectiveDefinition: "directives", 7 | UnionTypeDefinition: "unions", 8 | EnumTypeDefinition: "enums", 9 | } as const 10 | 11 | export const graphApiOperations = { 12 | query: "query", 13 | mutation: "mutation", 14 | subscription: "subscription", 15 | } as const 16 | 17 | export const graphSchemaCustomProps = ["args", "values", "interfaces", "directives"] as const 18 | -------------------------------------------------------------------------------- /src/graphapi/graphapi.rules.ts: -------------------------------------------------------------------------------- 1 | import { 2 | graphApiMergeAllOf, 3 | transformGraphApiComponents, 4 | transformGraphApiDirective, 5 | transformGraphApiDocument, 6 | } from "./graphapi.transform" 7 | import { allAnnotation, addNonBreaking } from "../core/constants" 8 | import { parentKeyChangeAnnotation } from "./graphapi.annotate" 9 | import { graphApiSchemaRules } from "./graphapi.schema" 10 | import { enumMappingResolver } from "../jsonSchema" 11 | import type { CompareRules } from "../types" 12 | 13 | export type GraphApiRulesOptions = { 14 | notMergeAllOf?: boolean 15 | } 16 | 17 | export const graphApiRules = ({ notMergeAllOf = false }: GraphApiRulesOptions = {}): CompareRules => { 18 | const argsSchemaRules = graphApiSchemaRules() 19 | const schemaRules = graphApiSchemaRules(true) 20 | 21 | return { 22 | transform: [...(notMergeAllOf ? [] : [graphApiMergeAllOf]), transformGraphApiDocument], 23 | 24 | "/queries": { 25 | "/*": schemaRules, 26 | }, 27 | "/mutations": { 28 | "/*": schemaRules, 29 | }, 30 | "/subscriptions": { 31 | "/*": schemaRules, 32 | }, 33 | 34 | "/components": { 35 | transform: [transformGraphApiComponents], 36 | "/*": { 37 | "/*": schemaRules, 38 | }, 39 | "/directives": { 40 | "/*": { 41 | annotate: parentKeyChangeAnnotation, 42 | transform: [transformGraphApiDirective], 43 | $: addNonBreaking, 44 | // TODO annotations 45 | "/title": { $: allAnnotation }, 46 | "/description": { $: allAnnotation }, 47 | "/locations": { 48 | mapping: enumMappingResolver, 49 | $: allAnnotation, 50 | }, 51 | "/repeatable": { $: allAnnotation }, 52 | "/args": argsSchemaRules, 53 | }, 54 | }, 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/graphapi/graphapi.schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | jsonSchemaKeyChange, 3 | jsonSchemaRules, 4 | schemaStatusChange, 5 | transformJsonSchema, 6 | transformJsonSchemaCombiners, 7 | } from "../jsonSchema" 8 | import { 9 | reverseClassifyRuleTransformer, 10 | transformComapreRules, 11 | allAnnotation, 12 | allDeprecated, 13 | nonBreaking, 14 | breaking, 15 | } from "../core" 16 | import { transformGraphSchema, transfromGraphSchemaDirective } from "./graphapi.transform" 17 | import { parentKeyChangeAnnotation, valuesAnnotationChange } from "./graphapi.annotate" 18 | import type { CompareRules } from "../types" 19 | 20 | export const graphApiSchemaRules = (response = false): CompareRules => { 21 | const graphSchemaRules = jsonSchemaRules({ 22 | notMergeAllOf: true, 23 | baseRules: { 24 | transform: [transformJsonSchemaCombiners(), transformJsonSchema(), transformGraphSchema], 25 | // graphschema extentions 26 | "/nullable": { $: [nonBreaking, breaking, nonBreaking], annotate: jsonSchemaKeyChange }, 27 | "/specifiedByURL": { $: allAnnotation }, 28 | "/args": () => ({ 29 | ...graphApiSchemaRules(), 30 | }), 31 | "/values": { 32 | "/*": { 33 | "/description": { 34 | annotate: valuesAnnotationChange, 35 | $: allAnnotation, 36 | }, 37 | "/deprecated": { 38 | annotate: valuesAnnotationChange, 39 | $: allDeprecated, 40 | "/reason": { 41 | annotate: valuesAnnotationChange, 42 | $: allDeprecated, 43 | }, 44 | }, 45 | }, 46 | }, 47 | "/deprecated": { 48 | $: allDeprecated, 49 | annotate: schemaStatusChange, 50 | "/reason": { 51 | annotate: parentKeyChangeAnnotation, 52 | $: allDeprecated, 53 | }, 54 | }, 55 | "/interfaces": { 56 | "/*": { $: allAnnotation }, 57 | }, 58 | "/directives": { 59 | "/*": { 60 | annotate: parentKeyChangeAnnotation, 61 | transform: [transfromGraphSchemaDirective], 62 | "/meta": { 63 | "/*": { 64 | annotate: parentKeyChangeAnnotation, 65 | $: allAnnotation, 66 | }, 67 | }, 68 | }, 69 | }, 70 | }, 71 | }) 72 | 73 | return response ? transformComapreRules(graphSchemaRules, reverseClassifyRuleTransformer) : graphSchemaRules 74 | } 75 | -------------------------------------------------------------------------------- /src/graphapi/graphapi.transform.ts: -------------------------------------------------------------------------------- 1 | import { graphapiMergeRules, merge } from "allof-merge" 2 | 3 | import { graphApiComponents, graphApiOperations, graphSchemaCustomProps } from "./graphapi.const" 4 | import { compareTransformationFactory } from "../core" 5 | 6 | export const transformGraphApiComponents = compareTransformationFactory((value, other) => { 7 | if (typeof value !== "object" || !value || typeof other !== "object" || !other) { 8 | return value 9 | } 10 | const result: any = { ...value } 11 | 12 | for (const comp of Object.values(graphApiComponents)) { 13 | // add empty component 14 | if (!(comp in value) && comp in other) { 15 | result[comp] = {} 16 | } 17 | } 18 | 19 | return result 20 | }) 21 | 22 | export const transformGraphSchema = compareTransformationFactory((value, other) => { 23 | if (typeof value !== "object" || !value || typeof other !== "object" || !other) { 24 | return value 25 | } 26 | const { nullable, ...result }: any = { ...value } 27 | 28 | for (const comp of graphSchemaCustomProps) { 29 | // add empty component 30 | if (!(comp in value) && comp in other) { 31 | result[comp] = {} 32 | } 33 | 34 | // add empty values items 35 | if (comp === "values" && comp in other) { 36 | const items = other[comp] as object 37 | for (const item of Object.keys(items)) { 38 | if (item in result[comp]) { 39 | continue 40 | } 41 | result[comp][item] = {} 42 | } 43 | } 44 | } 45 | 46 | if (nullable) { 47 | result.nullable = nullable 48 | } 49 | 50 | return result 51 | }) 52 | 53 | export const transfromGraphSchemaDirective = compareTransformationFactory((value, other) => { 54 | if (typeof value !== "object" || !value || typeof other !== "object" || !other) { 55 | return value 56 | } 57 | 58 | if ("meta" in value) { 59 | const { meta, ...rest } = value 60 | return { meta } 61 | } 62 | if ("meta" in other) { 63 | return { meta: {} } 64 | } 65 | 66 | return {} 67 | }) 68 | 69 | export const transformGraphApiDocument = compareTransformationFactory((value, other) => { 70 | if (typeof value !== "object" || !value || typeof other !== "object" || !other) { 71 | return value 72 | } 73 | const result: any = { ...value } 74 | 75 | for (const comp of Object.values(graphApiOperations)) { 76 | // add empty component 77 | if (!(comp in value) && comp in other) { 78 | result[comp] = {} 79 | } 80 | } 81 | 82 | return result 83 | }) 84 | 85 | export const transformGraphApiDirective = compareTransformationFactory((value, other) => { 86 | if (typeof value !== "object" || !value || typeof other !== "object" || !other) { 87 | return value 88 | } 89 | const result: any = { ...value } 90 | 91 | // add empty args object 92 | if (!("args" in value) && "args" in other) { 93 | result.args = { 94 | type: "object", 95 | properties: {}, 96 | } 97 | } 98 | 99 | // add empty location array 100 | if (!("locations" in value) && "locations" in other) { 101 | result.locations = [] 102 | } 103 | 104 | return result 105 | }) 106 | 107 | export const graphApiMergeAllOf = compareTransformationFactory((value) => { 108 | return merge(value, { rules: graphapiMergeRules, mergeCombinarySibling: true, mergeRefSibling: true }) 109 | }) 110 | -------------------------------------------------------------------------------- /src/graphapi/graphapi.utils.ts: -------------------------------------------------------------------------------- 1 | import type { JsonPath } from "json-crawl" 2 | 3 | export const isArgSchema = (path: JsonPath) => { 4 | const i = path.indexOf("args") 5 | return ( 6 | i === 2 || 7 | (i > 3 && path[i - 2] === "properties" && path[i - 1] !== "properties") || 8 | (i > 2 && path[i - 2] === "directives" && path[i - 1] !== "directives") 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/graphapi/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./graphapi.annotate" 2 | export * from "./graphapi.compare" 3 | export * from "./graphapi.const" 4 | export * from "./graphapi.rules" 5 | export * from "./graphapi.utils" 6 | export * from "./graphapi.schema" 7 | export * from "./graphapi.transform" 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api" 2 | export * from "./core" 3 | export * from "./jsonSchema" 4 | export * from "./graphapi" 5 | export * from "./asyncapi" 6 | export * from "./openapi" 7 | export * from "./types" 8 | export * from "./utils" 9 | -------------------------------------------------------------------------------- /src/jsonSchema/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./jsonSchema.compare" 2 | export * from "./jsonSchema.consts" 3 | export * from "./jsonSchema.resolver" 4 | export * from "./jsonSchema.classify" 5 | export * from "./jsonSchema.mapping" 6 | export * from "./jsonSchema.types" 7 | export * from "./jsonSchema.transform" 8 | export * from "./jsonSchema.utils" 9 | export * from "./jsonSchema.rules" 10 | export * from "./jsonSchema.annotate" 11 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.annotate.ts: -------------------------------------------------------------------------------- 1 | import type { AnnotateHook, ChangeAnnotationResolver } from "../types" 2 | import { createAnnotation, annotationTemplate as t } from "../core" 3 | import { getTarget } from "./jsonSchema.utils" 4 | import { isNumber, isString } from "../utils" 5 | 6 | export const jsonSchemaAnnotations = { 7 | add: "[Added] {{text}}", 8 | add_target: "[Added] {{text}} to `{{target}}`", 9 | remove: "[Removed] {{text}}", 10 | remove_target: "[Removed] {{text}} from `{{target}}`", 11 | replace: "[Replaced] {{text}}", 12 | replace_target: "[Replaced] {{text}} of `{{target}}`", 13 | rename: "[Renamed] {{text}}", 14 | rename_target: "[Renamed] {{text}} of `{{target}}`", 15 | status: "{{key}} status", 16 | validation: "{{key}} validator", 17 | annotation: "annotation ({{key}})", 18 | enum: "possible values", 19 | format: "value format", 20 | default: "default value", 21 | const: "possible value", 22 | type: "type definition", 23 | nullable: "possbile nullable value", 24 | property: "property `{{key}}`", 25 | arratItem: "array item with index `{{key}}`", 26 | patternProperty: "property with key pattern `{{key}}`", 27 | additionalProperties: "schema for additional properties", 28 | arrayItems: "schema for array items", 29 | additionalArrayItems: "schema for additional array items", 30 | oneOfItem: "oneOf schema", 31 | anyOfItem: "anyOf schema", 32 | allOfItem: "allOf schema", 33 | } as const 34 | 35 | export const jsonSchemaAnnotationHook: AnnotateHook = (diff, ctx) => { 36 | const annotate = ctx.rules?.annotate 37 | 38 | if (!annotate) { 39 | return "" 40 | } 41 | 42 | return createAnnotation(annotate(diff, ctx), jsonSchemaAnnotations) 43 | } 44 | 45 | export const schemaAnnotationChange: ChangeAnnotationResolver = ({ action, path }) => { 46 | const key = path[path.length - 1] 47 | 48 | return { template: action, params: { text: { template: "annotation", params: { key } }, target: getTarget(path) } } 49 | } 50 | 51 | export const schemaExampleChange: ChangeAnnotationResolver = ({ action, path }) => { 52 | const key = path[path.length - 1] 53 | 54 | return t(action, { text: t("annotation", { key: "example" }), target: getTarget(path) }) 55 | } 56 | 57 | export const schemaValidationChange: ChangeAnnotationResolver = ({ action, path }) => { 58 | const key = path[path.length - 1] 59 | 60 | return t(action, { text: t("validation", { key }), target: getTarget(path) }) 61 | } 62 | 63 | export const schemaStatusChange: ChangeAnnotationResolver = ({ path }, ctx) => { 64 | const key = path[path.length - 1] 65 | 66 | if (ctx.after.value) { 67 | return t("add", { text: t("status", { key }), target: getTarget(path) }) 68 | } 69 | if (ctx.before.value) { 70 | return t("remove", { text: t("status", { key }), target: getTarget(path) }) 71 | } 72 | } 73 | 74 | export const jsonSchemaKeyChange: ChangeAnnotationResolver = ({ action, path }) => { 75 | const key = path[path.length - 1] 76 | 77 | if (isNumber(key)) { 78 | return 79 | } 80 | 81 | return t(action, { target: getTarget(path), text: t(key) }) 82 | } 83 | 84 | export const schemaKeyItemChange: ChangeAnnotationResolver = ({ action, path }, ctx) => { 85 | const key = path[path.length - 1] 86 | const { value } = action === "add" ? ctx.after : ctx.before 87 | const parentKey = path.length > 1 ? path[path.length - 2] : "" 88 | const parentTarget = getTarget(path.slice(0, -1)) 89 | const target = getTarget(path) 90 | 91 | switch (parentKey) { 92 | case "enum": 93 | return t("replace", { text: t("enum"), target }) 94 | case "properties": 95 | return isString(key) ? t(action, { text: t("property", { key }), target: parentTarget }) : undefined 96 | case "items": 97 | return isNumber(key) ? t(action, { text: t("arratItem", { key }), target: parentTarget }) : undefined 98 | case "patternProperties": 99 | return isString(key) ? t(action, { text: t("patternProperty", { key }), target: parentTarget }) : undefined 100 | case "oneOf": 101 | case "anyOf": 102 | case "allOf": 103 | return t(action, { text: t(`${parentKey}Item`), target }) 104 | case "required": 105 | return isString(value) 106 | ? t(action, { text: t("status", { key: parentKey }), target: target ? `${target}.${value}` : value }) 107 | : undefined 108 | } 109 | return 110 | } 111 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.classify.ts: -------------------------------------------------------------------------------- 1 | import { transformClassifyRule, getParentContext, breaking, nonBreaking, unclassified } from "../core" 2 | import { isExist, isString, isNumber, getArrayValue, getKeyValue } from "../utils" 3 | import type { ClassifyRule, DiffType, DiffTypeClassifier } from "../types" 4 | import { isValidSchemaTypes } from "./jsonSchema.utils" 5 | 6 | export const breakingIf = (v: boolean): DiffType => (v ? breaking : nonBreaking) 7 | export const breakingIfAfterTrue: DiffTypeClassifier = ({ after }): DiffType => breakingIf(!!after.value) 8 | 9 | export const typeClassifier = (types: string[] | string, base: ClassifyRule): ClassifyRule => { 10 | const _types = Array.isArray(types) ? types : [types] 11 | if (_types.includes("number")) { 12 | _types.push("integer") 13 | } 14 | 15 | return transformClassifyRule(base, (diffType, { before, after }, action) => { 16 | return isValidSchemaTypes(_types, action === "remove" ? before.parent : after.parent) ? diffType : unclassified 17 | }) 18 | } 19 | 20 | export const maxClassifier: ClassifyRule = [ 21 | breaking, 22 | nonBreaking, 23 | ({ before, after }) => breakingIf(!isNumber(before.value) || !isNumber(after.value) || before.value > after.value), 24 | ] 25 | 26 | export const minClassifier: ClassifyRule = [ 27 | breaking, 28 | nonBreaking, 29 | ({ before, after }) => breakingIf(!isNumber(before.value) || !isNumber(after.value) || before.value < after.value), 30 | ] 31 | 32 | export const exclusiveClassifier: ClassifyRule = [breakingIfAfterTrue, nonBreaking, breakingIfAfterTrue] 33 | 34 | export const booleanClassifier: ClassifyRule = [breakingIfAfterTrue, nonBreaking, breakingIfAfterTrue] 35 | 36 | export const multipleOfClassifier: ClassifyRule = [ 37 | breaking, 38 | nonBreaking, 39 | ({ before, after }) => 40 | breakingIf(!!(!isNumber(before.value) || !isNumber(after.value) || before.value % after.value)), 41 | ] 42 | 43 | export const requiredItemClassifyRule: ClassifyRule = [ 44 | ({ after }) => 45 | !isString(after.value) || isExist(getParentContext(after, "", "properties", after.value, "default")?.value) 46 | ? nonBreaking 47 | : breaking, 48 | nonBreaking, 49 | ({ after }) => 50 | !isString(after.value) || isExist(getParentContext(after, "", "properties", after.value, "default")?.value) 51 | ? nonBreaking 52 | : breaking, 53 | ] 54 | 55 | export const propertyClassifyRule: ClassifyRule = [ 56 | ({ after }) => 57 | !isExist(getKeyValue(after.value, "default")) && 58 | getArrayValue(getParentContext(after, "", "required")?.value)?.includes(after.key) 59 | ? breaking 60 | : nonBreaking, 61 | nonBreaking, 62 | unclassified, 63 | nonBreaking, 64 | ({ before }) => 65 | getArrayValue(getParentContext(before, "", "required")?.value)?.includes(before.key) ? breaking : nonBreaking, 66 | unclassified, 67 | ] 68 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.compare.ts: -------------------------------------------------------------------------------- 1 | import type { JsonSchemaCompareOptions } from "./jsonSchema.types" 2 | import { jsonSchemaAnnotationHook } from "./jsonSchema.annotate" 3 | import type { CompareResult, SourceContext } from "../types" 4 | import { jsonSchemaRules } from "./jsonSchema.rules" 5 | import { compare } from "../core" 6 | 7 | export const compareJsonSchema = ( 8 | before: unknown, 9 | after: unknown, 10 | options: JsonSchemaCompareOptions = {}, 11 | context: SourceContext = {}, 12 | ): CompareResult => { 13 | // set default options 14 | const _options = { 15 | ...options, 16 | rules: options.rules ?? jsonSchemaRules({ notMergeAllOf: options.notMergeAllOf }), 17 | annotateHook: options.annotateHook ?? jsonSchemaAnnotationHook, 18 | } 19 | 20 | return compare(before, after, _options, context) 21 | } 22 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.consts.ts: -------------------------------------------------------------------------------- 1 | export const jsonSchemaTypes = ["any", "string", "number", "integer", "boolean", "null", "array", "object"] as const 2 | 3 | export const jsonSchemaNodeMetaProps = ["deprecated", "readOnly", "writeOnly", "externalDocs"] as const 4 | 5 | export const jsonSchemaCommonProps = [ 6 | "type", 7 | "description", 8 | "title", 9 | "enum", 10 | "default", 11 | "examples", 12 | "format", 13 | "const", 14 | ] as const 15 | 16 | export const jsonSchemaDefinitionsPath = ["$defs", "definitions"] 17 | 18 | export const jsonSchemaValidators = { 19 | any: [], 20 | boolean: [], 21 | null: [], 22 | string: ["minLength", "maxLength", "pattern"], 23 | number: ["multipleOf", "minimum", "exclusiveMinimum", "maximum", "exclusiveMaximum"], 24 | integer: ["multipleOf", "minimum", "exclusiveMinimum", "maximum", "exclusiveMaximum"], 25 | object: [ 26 | "required", 27 | "minProperties", 28 | "maxProperties", 29 | "propertyNames", 30 | "properties", 31 | "patternProperties", 32 | "additionalProperties", 33 | ], 34 | array: ["minItems", "maxItems", "uniqueItems", "items", "additionalItems"], 35 | } 36 | 37 | export const jsonSchemaTypeProps: Record = { 38 | any: [...jsonSchemaValidators.any, ...jsonSchemaCommonProps, ...jsonSchemaNodeMetaProps], 39 | boolean: [...jsonSchemaValidators.boolean, ...jsonSchemaCommonProps, ...jsonSchemaNodeMetaProps], 40 | null: [...jsonSchemaValidators.null, ...jsonSchemaCommonProps, ...jsonSchemaNodeMetaProps], 41 | string: [...jsonSchemaValidators.string, ...jsonSchemaCommonProps, ...jsonSchemaNodeMetaProps], 42 | number: [...jsonSchemaValidators.number, ...jsonSchemaCommonProps, ...jsonSchemaNodeMetaProps], 43 | integer: [...jsonSchemaValidators.integer, ...jsonSchemaCommonProps, ...jsonSchemaNodeMetaProps], 44 | object: [...jsonSchemaValidators.object, ...jsonSchemaCommonProps, ...jsonSchemaNodeMetaProps], 45 | array: [...jsonSchemaValidators.array, ...jsonSchemaCommonProps, ...jsonSchemaNodeMetaProps], 46 | } 47 | 48 | export const jsonSchemaAllowedSibling = ["$defs", "definitions", "$schema", "$id"] 49 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.mapping.ts: -------------------------------------------------------------------------------- 1 | import type { MapKeysResult, MappingResolver } from "../types" 2 | import { objectMappingResolver } from "../core" 3 | 4 | export const jsonSchemaMappingResolver: MappingResolver = (before, after, ctx) => { 5 | const { added, removed, mapped } = objectMappingResolver(before, after, ctx) 6 | 7 | const beforeCombinaryIndex = removed.findIndex((item) => item === "oneOf" || item === "anyOf") 8 | const afterCombinaryIndex = added.findIndex((item) => item === "oneOf" || item === "anyOf") 9 | 10 | if (beforeCombinaryIndex < 0 || afterCombinaryIndex < 0) { 11 | return { added, removed, mapped } 12 | } 13 | 14 | const [bkey] = removed.splice(beforeCombinaryIndex, 1) 15 | const [akey] = added.splice(afterCombinaryIndex, 1) 16 | mapped[bkey] = akey 17 | 18 | return { added, removed, mapped } 19 | } 20 | 21 | export const enumMappingResolver: MappingResolver = (before, after) => { 22 | const result: MapKeysResult = { added: [], removed: [], mapped: {} } 23 | 24 | const afterItems = [...after] 25 | const unmappedAfter = new Set(after.keys()) 26 | const unmappedBefore: number[] = [] 27 | 28 | for (let i = 0; i < before.length; i++) { 29 | const _afterIndex = afterItems.indexOf(before[i]) 30 | 31 | if (_afterIndex < 0) { 32 | unmappedBefore.push(i) 33 | } else { 34 | // mapped items 35 | result.mapped[i] = _afterIndex 36 | unmappedAfter.delete(_afterIndex) 37 | } 38 | } 39 | 40 | let j = 0 41 | for (const i of unmappedAfter) { 42 | if (j < unmappedBefore.length) { 43 | // replaced items 44 | result.mapped[unmappedBefore[j++]] = i 45 | } else { 46 | // added items 47 | result.added.push(i) 48 | } 49 | } 50 | 51 | // removed items 52 | for (let i = j; i < unmappedBefore.length; i++) { 53 | result.removed.push(unmappedBefore[i]) 54 | } 55 | 56 | return result 57 | } 58 | 59 | export const requiredMappingResolver: MappingResolver = (before, after) => { 60 | const result: MapKeysResult = { added: [], removed: [], mapped: {} } 61 | 62 | const afterItems = [...after] 63 | const unmappedAfter = new Set(after.keys()) 64 | 65 | for (let i = 0; i < before.length; i++) { 66 | const _afterIndex = afterItems.indexOf(before[i]) 67 | 68 | if (_afterIndex < 0) { 69 | result.removed.push(i) 70 | } else { 71 | // mapped items 72 | result.mapped[i] = _afterIndex 73 | unmappedAfter.delete(_afterIndex) 74 | } 75 | } 76 | 77 | for (const i of unmappedAfter) { 78 | // added items 79 | result.added.push(i) 80 | } 81 | 82 | return result 83 | } 84 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.resolver.ts: -------------------------------------------------------------------------------- 1 | import { getNodeRules } from "json-crawl" 2 | import { isRefNode } from "allof-merge" 3 | 4 | import { diffFactory, convertDiffToMeta, createMergeMeta, compare, createChildContext, DIFF_META_KEY } from "../core" 5 | import { buildPath, getCompareId, getRef, isCycleRef, resolveRef } from "./jsonSchema.utils" 6 | import type { CompareResultCache, JsonSchemaComapreCache } from "./jsonSchema.types" 7 | import type { ComapreContext, CompareResolver, Diff } from "../types" 8 | import { isArray } from "../utils" 9 | 10 | export const combinaryCompareResolver: CompareResolver = (ctx) => { 11 | const { before, after, options } = ctx 12 | const { arrayMeta, metaKey = DIFF_META_KEY } = options 13 | 14 | if (!isArray(before.value) || !isArray(after.value)) { 15 | const diff = diffFactory.replaced(before.path, before.value, after.value, ctx) 16 | return { diffs: [diff], merged: after.value, rootMergeMeta: convertDiffToMeta(diff) } 17 | } 18 | // match combinaries 19 | const afterMatched = new Set(after.value.keys()) 20 | const beforeMached = new Set(before.value.keys()) 21 | const comparedItems = [] 22 | const _merged: any = [] 23 | const _diffs: Diff[] = [] 24 | 25 | const rules = getNodeRules(ctx.rules, "*", before.path, before.value) 26 | 27 | // compare all combinations, find min diffs 28 | for (const i of before.value.keys()) { 29 | const _before = before.value[i] 30 | for (const j of after.value.keys()) { 31 | if (!afterMatched.has(j)) { 32 | continue 33 | } 34 | const _after = after.value[j] 35 | 36 | const { diffs, merged } = compare( 37 | _before, 38 | _after, 39 | { ...options, rules }, 40 | { 41 | before: { jsonPath: [...before.path, i], source: before.root }, 42 | after: { jsonPath: [...after.path, j], source: after.root }, 43 | }, 44 | ) 45 | 46 | if (!diffs.length) { 47 | afterMatched.delete(j) 48 | beforeMached.delete(i) 49 | _merged[j] = _after 50 | break 51 | } 52 | comparedItems.push({ before: i, after: j, diffs, merged }) 53 | } 54 | } 55 | 56 | comparedItems.sort((a, b) => a.diffs.length - b.diffs.length) 57 | 58 | for (const compared of comparedItems) { 59 | if (!afterMatched.has(compared.after) || !beforeMached.has(compared.before)) { 60 | continue 61 | } 62 | afterMatched.delete(compared.after) 63 | beforeMached.delete(compared.before) 64 | _merged[compared.after] = compared.merged 65 | _diffs.push(...compared.diffs) 66 | } 67 | 68 | const arrayMetaDiffs: Diff[] = [] 69 | for (const i of beforeMached.values()) { 70 | const diff = diffFactory.removed([...before.path, _merged.length], before.value[i], createChildContext(ctx, i, "")) 71 | _merged.push(before.value[i]) 72 | arrayMetaDiffs.push(diff) 73 | _diffs.push(diff) 74 | } 75 | 76 | for (const j of afterMatched.values()) { 77 | _merged[j] = after.value[j] 78 | const diff = diffFactory.added([...after.path, j], after.value[j], createChildContext(ctx, "", j)) 79 | arrayMetaDiffs.push(diff) 80 | _diffs.push(diff) 81 | } 82 | const rootArrayMeta = createMergeMeta(arrayMetaDiffs) 83 | 84 | if (arrayMeta) { 85 | _merged[metaKey] = rootArrayMeta 86 | } 87 | 88 | return { 89 | diffs: _diffs, 90 | merged: _merged, 91 | ...(!arrayMeta && Object.keys(rootArrayMeta).length ? { rootMergeMeta: { array: rootArrayMeta } } : {}), 92 | } 93 | } 94 | 95 | export const createRefsCompareResolver = (cache: JsonSchemaComapreCache = {}): CompareResolver => { 96 | cache.results = cache.results ?? new Map() 97 | cache.bRefs = cache.bRefs ?? {} 98 | cache.aRefs = cache.aRefs ?? {} 99 | 100 | const { results, bRefs, aRefs } = cache 101 | 102 | const resolver: CompareResolver = (ctx: ComapreContext) => { 103 | const { before, after, rules, options } = ctx 104 | // check if current path has already been compared via $refs 105 | const bPath = buildPath(before.path) 106 | const aPath = buildPath(after.path) 107 | 108 | let compareRefsId = getCompareId(bPath, aPath) 109 | if (results.has(compareRefsId)) { 110 | return results.get(compareRefsId) 111 | } 112 | 113 | // normalize ref 114 | const bRef = isRefNode(before.value) ? getRef(before.value.$ref) : "" 115 | const aRef = isRefNode(after.value) ? getRef(after.value.$ref) : "" 116 | 117 | if (!bRef && !aRef) { 118 | return 119 | } 120 | 121 | // skip if cycle ref 122 | if (isCycleRef(bRef, `#${bPath}`, bRefs) || isCycleRef(aRef, `#${aPath}`, aRefs)) { 123 | return 124 | } 125 | 126 | // save refs to refs history 127 | if (bRef) { 128 | bRefs[bRef] = [...(bRefs[bRef] ?? []), `#${bPath}`] 129 | } 130 | if (aRef) { 131 | aRefs[aRef] = [...(aRefs[aRef] ?? []), `#${aPath}`] 132 | } 133 | 134 | // compare $refs 135 | if (bRef && aRef) { 136 | compareRefsId = getCompareId(bRef, aRef) 137 | 138 | const _result = results.get(compareRefsId) 139 | if (results.has(compareRefsId) && _result) { 140 | const { path, diffs, ...rest } = _result 141 | return { 142 | ...rest, 143 | diffs: diffs.map((diff) => { 144 | const _diff: Diff = { ...diff, description: "", path: [...before.path, ...diff.path.slice(path.length)] } 145 | _diff.description = options.annotateHook?.(_diff, ctx) 146 | return _diff 147 | }), 148 | } 149 | } 150 | } 151 | 152 | // compare $refs content 153 | const _before = resolveRef(before.value, before.root) 154 | const _after = resolveRef(after.value, after.root) 155 | 156 | if (_before === undefined || _after === undefined) { 157 | return 158 | } 159 | 160 | // compare content 161 | const result = compare( 162 | _before, 163 | _after, 164 | { ...options, rules }, 165 | { 166 | before: { jsonPath: before.path, source: before.root }, 167 | after: { jsonPath: after.path, source: after.root }, 168 | }, 169 | ) 170 | 171 | // save compare result 172 | if (bRef && aRef) { 173 | results.set(compareRefsId, { ...result, path: before.path }) 174 | } 175 | 176 | return result 177 | } 178 | 179 | return resolver 180 | } 181 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.rules.ts: -------------------------------------------------------------------------------- 1 | import { 2 | breaking, 3 | nonBreaking, 4 | allAnnotation, 5 | allBreaking, 6 | allUnclassified, 7 | onlyAddBreaking, 8 | allDeprecated, 9 | allNonBreaking, 10 | unclassified, 11 | reverseClassifyRuleTransformer, 12 | transformComapreRules, 13 | } from "../core" 14 | import { 15 | schemaAnnotationChange, 16 | schemaExampleChange, 17 | jsonSchemaKeyChange, 18 | schemaKeyItemChange, 19 | schemaStatusChange, 20 | schemaValidationChange, 21 | } from "./jsonSchema.annotate" 22 | import { 23 | booleanClassifier, 24 | exclusiveClassifier, 25 | maxClassifier, 26 | minClassifier, 27 | multipleOfClassifier, 28 | propertyClassifyRule, 29 | requiredItemClassifyRule, 30 | typeClassifier, 31 | } from "./jsonSchema.classify" 32 | import { transformJsonSchema, transformJsonSchemaCombiners, jsonSchemaMergeAllOf } from "./jsonSchema.transform" 33 | import { enumMappingResolver, jsonSchemaMappingResolver, requiredMappingResolver } from "./jsonSchema.mapping" 34 | import { combinaryCompareResolver, createRefsCompareResolver } from "./jsonSchema.resolver" 35 | import type { ChangeAnnotationResolver, ClassifyRule, CompareRules } from "../types" 36 | import type { JsonSchemaRulesOptions } from "./jsonSchema.types" 37 | 38 | const annotationRule: CompareRules = { $: allAnnotation, annotate: schemaAnnotationChange } 39 | const simpleRule = (classify: ClassifyRule, annotate: ChangeAnnotationResolver) => ({ $: classify, annotate }) 40 | 41 | const arrayItemsRules = (value: unknown, rules: CompareRules): CompareRules => { 42 | return Array.isArray(value) 43 | ? { 44 | "/*": () => ({ 45 | ...rules, 46 | $: allBreaking, 47 | annotate: schemaKeyItemChange, 48 | }), 49 | } 50 | : { 51 | ...rules, 52 | $: allNonBreaking, 53 | annotate: jsonSchemaKeyChange, 54 | } 55 | } 56 | 57 | export const jsonSchemaRules = ({ 58 | baseRules = {}, 59 | notMergeAllOf, 60 | version = "draft-04", 61 | cache = {}, 62 | }: JsonSchemaRulesOptions = {}): CompareRules => { 63 | const rules: CompareRules = { 64 | // important to createCompareRefResolver once for cycle refs cache 65 | compare: createRefsCompareResolver(cache), 66 | transform: [transformJsonSchemaCombiners(), transformJsonSchema(version)], 67 | mapping: jsonSchemaMappingResolver, 68 | 69 | "/title": annotationRule, 70 | "/multipleOf": simpleRule(typeClassifier("number", multipleOfClassifier), schemaValidationChange), 71 | "/maximum": simpleRule(typeClassifier("number", maxClassifier), schemaValidationChange), 72 | "/minimum": simpleRule(typeClassifier("number", minClassifier), schemaValidationChange), 73 | ...(version === "draft-04" 74 | ? { 75 | "/exclusiveMaximum": simpleRule(typeClassifier("number", exclusiveClassifier), schemaValidationChange), 76 | "/exclusiveMinimum": simpleRule(typeClassifier("number", exclusiveClassifier), schemaValidationChange), 77 | } 78 | : { 79 | "/exclusiveMaximum": simpleRule(typeClassifier("number", maxClassifier), schemaValidationChange), 80 | "/exclusiveMinimum": simpleRule(typeClassifier("number", minClassifier), schemaValidationChange), 81 | }), 82 | "/maxLength": simpleRule(typeClassifier("string", maxClassifier), schemaValidationChange), 83 | "/minLength": simpleRule(typeClassifier("string", minClassifier), schemaValidationChange), 84 | "/pattern": simpleRule(typeClassifier("string", [breaking, nonBreaking, breaking]), schemaValidationChange), 85 | "/maxItems": simpleRule(typeClassifier("array", maxClassifier), schemaValidationChange), 86 | "/minItems": simpleRule(typeClassifier("array", minClassifier), schemaValidationChange), 87 | "/uniqueItems": simpleRule(typeClassifier("array", booleanClassifier), schemaValidationChange), 88 | "/maxProperties": simpleRule(typeClassifier("object", maxClassifier), schemaValidationChange), 89 | "/minProperties": simpleRule(typeClassifier("object", minClassifier), schemaValidationChange), 90 | "/required": { 91 | mapping: requiredMappingResolver, 92 | "/*": simpleRule(requiredItemClassifyRule, schemaKeyItemChange), 93 | }, 94 | "/enum": { 95 | mapping: enumMappingResolver, 96 | annotate: jsonSchemaKeyChange, 97 | "/*": { $: [nonBreaking, breaking, breaking], annotate: schemaKeyItemChange }, 98 | }, 99 | "/const": simpleRule([breaking, nonBreaking, breaking], jsonSchemaKeyChange), 100 | "/type": { 101 | $: [breaking, nonBreaking, breaking], 102 | annotate: jsonSchemaKeyChange, 103 | "/*": { $: [nonBreaking, breaking, breaking] }, 104 | }, 105 | "/not": () => ({ 106 | // TODO check 107 | ...transformComapreRules(rules, reverseClassifyRuleTransformer), 108 | $: allBreaking, 109 | }), 110 | "/allOf": { 111 | compare: combinaryCompareResolver, 112 | "/*": () => ({ 113 | ...rules, 114 | $: allBreaking, 115 | annotate: schemaKeyItemChange, 116 | }), 117 | }, 118 | "/oneOf": { 119 | compare: combinaryCompareResolver, 120 | "/*": () => ({ 121 | ...rules, 122 | $: [nonBreaking, breaking, breaking], 123 | annotate: schemaKeyItemChange, 124 | }), 125 | }, 126 | "/anyOf": { 127 | compare: combinaryCompareResolver, 128 | "/*": () => ({ 129 | ...rules, 130 | $: [nonBreaking, breaking, breaking], 131 | annotate: schemaKeyItemChange, 132 | }), 133 | }, 134 | "/items": ({ value }) => arrayItemsRules(value, rules), 135 | "/additionalItems": () => ({ 136 | ...rules, 137 | $: [nonBreaking, breaking, unclassified], 138 | annotate: jsonSchemaKeyChange, 139 | }), 140 | "/properties": { 141 | "/*": () => ({ 142 | ...rules, 143 | $: propertyClassifyRule, 144 | annotate: schemaKeyItemChange, 145 | }), 146 | }, 147 | "/additionalProperties": () => ({ 148 | ...rules, 149 | $: allNonBreaking, 150 | annotate: jsonSchemaKeyChange, 151 | }), 152 | "/patternProperties": { 153 | "/*": () => ({ 154 | ...rules, 155 | $: [breaking, nonBreaking, unclassified], 156 | annotate: schemaKeyItemChange, 157 | }), 158 | }, 159 | "/propertyNames": () => ({ ...rules, $: onlyAddBreaking, annotate: schemaValidationChange }), 160 | "/description": annotationRule, 161 | "/format": { $: [breaking, nonBreaking, breaking], annotate: jsonSchemaKeyChange }, 162 | "/default": { $: [nonBreaking, breaking, breaking], annotate: jsonSchemaKeyChange }, 163 | // TODO "/dependencies": {}, 164 | "/definitions": { 165 | "/*": () => ({ 166 | ...rules, 167 | $: allNonBreaking, 168 | }), 169 | }, 170 | "/$defs": { 171 | "/*": () => ({ 172 | ...rules, 173 | $: allNonBreaking, 174 | }), 175 | }, 176 | "/readOnly": { $: booleanClassifier, annotate: schemaStatusChange }, 177 | "/writeOnly": { $: booleanClassifier, annotate: schemaStatusChange }, 178 | "/deprecated": { $: allDeprecated, annotate: schemaStatusChange }, 179 | "/examples": { 180 | $: allAnnotation, 181 | annotate: schemaAnnotationChange, 182 | "/*": { $: allAnnotation, annotate: schemaExampleChange }, 183 | }, 184 | 185 | // unknown tags 186 | "/**": { 187 | annotate: schemaAnnotationChange, 188 | $: allUnclassified, 189 | }, 190 | ...baseRules, 191 | } 192 | 193 | return notMergeAllOf 194 | ? rules 195 | : { 196 | ...rules, 197 | transform: [...(rules.transform ?? []), jsonSchemaMergeAllOf(version)], 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.transform.ts: -------------------------------------------------------------------------------- 1 | import { jsonSchemaMergeRules, merge } from "allof-merge" 2 | 3 | import { 4 | createEmptyCombiner, 5 | inferTypes, 6 | mergeAllOfSibling, 7 | mergeCombinarySibling, 8 | mergeRefSibling, 9 | } from "./jsonSchema.utils" 10 | import type { CompareTransformResolver, TransformResolver } from "../types" 11 | import { jsonSchemaAllowedSibling } from "./jsonSchema.consts" 12 | import { compareTransformationFactory } from "../core" 13 | import { isArray, isObject, isString } from "../utils" 14 | 15 | export const valuesTransformation = ( 16 | values: Record<"before" | "after", T>, 17 | resolver: TransformResolver, 18 | ) => { 19 | values.before = resolver(values.before, values.after) 20 | values.after = resolver(values.after, values.before) 21 | } 22 | 23 | export const transformJsonSchemaCombiners = 24 | (allowedSibling = jsonSchemaAllowedSibling): CompareTransformResolver => 25 | (before, after) => { 26 | if (!isObject(before) || !isObject(after)) { 27 | return [before, after] 28 | } 29 | 30 | const values = { before, after } 31 | 32 | valuesTransformation(values, (value) => { 33 | if ("oneOf" in value) { 34 | return mergeCombinarySibling(value, "oneOf", allowedSibling) 35 | } 36 | if ("anyOf" in value) { 37 | return mergeCombinarySibling(value, "anyOf", allowedSibling) 38 | } 39 | if ("allOf" in value) { 40 | return mergeAllOfSibling(value, allowedSibling) 41 | } 42 | if ("$ref" in value) { 43 | return mergeRefSibling(value, allowedSibling) 44 | } 45 | return value 46 | }) 47 | 48 | for (const prop of ["oneOf", "anyOf"]) { 49 | if (prop in values.before && !("oneOf" in values.after || "anyOf" in values.after)) { 50 | return [values.before, createEmptyCombiner(values.after, prop, allowedSibling)] 51 | } 52 | if (prop in values.after && !("oneOf" in values.before || "anyOf" in values.before)) { 53 | return [createEmptyCombiner(values.before, prop, allowedSibling), values.after] 54 | } 55 | } 56 | 57 | return [values.before, values.after] 58 | } 59 | 60 | export const createFields = 61 | (...fields: string[]): CompareTransformResolver => 62 | (before, after) => { 63 | if (!isObject(before) || !isObject(after)) { 64 | return [before, after] 65 | } 66 | const values = { before: { ...before }, after: { ...after } } 67 | 68 | valuesTransformation(values, (value, other) => { 69 | for (const prop of fields) { 70 | if (prop in other && isObject(other[prop]) && !(prop in value)) { 71 | value[prop] = Array.isArray(other[prop]) ? [] : {} 72 | } 73 | } 74 | return value 75 | }) 76 | 77 | return [values.before, values.after] 78 | } 79 | 80 | export const transformJsonSchema = 81 | (version: "draft-04" | "2020-12" = "2020-12"): CompareTransformResolver => 82 | (before, after) => { 83 | if (!isObject(before) || !isObject(after)) { 84 | return [before, after] 85 | } 86 | const values = { before: { ...before }, after: { ...after } } 87 | 88 | // create missing properties: enum, required, properties, items, definitions 89 | valuesTransformation(values, (value, other) => { 90 | for (const prop of ["enum", "required", "properties", "patternProperties", "definitions", "examples"]) { 91 | if (prop in other && isObject(other[prop]) && !(prop in value)) { 92 | value[prop] = Array.isArray(other[prop]) ? [] : {} 93 | } 94 | } 95 | return value 96 | }) 97 | 98 | valuesTransformation(values, (value, other) => { 99 | // transform const into enum 100 | if ("const" in value && "enum" in other) { 101 | const { const: v, ...rest } = value 102 | return { ...rest, enum: [v] } 103 | } 104 | 105 | return value 106 | }) 107 | 108 | valuesTransformation(values, (value) => { 109 | // remove not unique items from enum 110 | if (Array.isArray(value.enum)) { 111 | value.enum = value.enum.filter((v, i, arr) => arr.indexOf(v) === i) 112 | } 113 | return value 114 | }) 115 | 116 | valuesTransformation(values, (value) => { 117 | // remove not unique items from required 118 | if ("required" in value && Array.isArray(value.required)) { 119 | const required = value.required.filter((v, i, arr) => isString(v) && arr.indexOf(v) === i) 120 | return { ...value, required } 121 | } 122 | 123 | return value 124 | }) 125 | 126 | valuesTransformation(values, (value, other) => { 127 | // transform items 128 | if ("items" in other && isArray(other.items)) { 129 | if ("items" in value && typeof value.items === "object") { 130 | return isArray(value.items) 131 | ? value 132 | : { ...value, items: other.items.map(() => value.items), additionalItems: value.items } 133 | } 134 | return { ...value, items: [] } 135 | } 136 | 137 | return value 138 | }) 139 | 140 | valuesTransformation(values, (value, other) => { 141 | if (!("type" in value) && "type" in other) { 142 | const types = inferTypes(value) 143 | if (types.length) { 144 | value.type = isString(other.type) && types.includes(other.type) ? other.type : types 145 | } 146 | } 147 | return value 148 | }) 149 | 150 | valuesTransformation(values, (value) => { 151 | // 1. convert exclusiveMinimum from boolean to number 152 | // 2. remove minimum if exclusiveMinimum exists 153 | if ("exclusiveMinimum" in value && typeof value.exclusiveMinimum === "boolean" && "minimum" in value) { 154 | const { minimum, exclusiveMinimum, ...rest } = value 155 | return { ...rest, exclusiveMinimum: minimum } 156 | } 157 | return value 158 | }) 159 | 160 | valuesTransformation(values, (value) => { 161 | // 1. convert exclusiveMaximum from boolean to number 162 | // 2. remove maximum if exclusiveMaximum exists 163 | if ("exclusiveMaximum" in value && typeof value.exclusiveMaximum === "boolean" && "maximum" in value) { 164 | const { maximum, exclusiveMaximum, ...rest } = value 165 | return { ...rest, exclusiveMaximum: maximum } 166 | } 167 | return value 168 | }) 169 | 170 | valuesTransformation(values, (value, other) => { 171 | // convert example to array of examples 172 | if ("example" in value && "examples" in other) { 173 | const { example, ...rest } = value 174 | const examples = "examples" in value && Array.isArray(value.examples) ? value.examples : [] 175 | return { ...rest, examples: [...examples, example] } 176 | } 177 | return value 178 | }) 179 | 180 | return [values.before, values.after] 181 | } 182 | 183 | export const jsonSchemaMergeAllOf = (version: "draft-04" | "2020-12") => 184 | compareTransformationFactory((value) => { 185 | const draft = version === "draft-04" ? version : "draft-06" 186 | return merge(value, { rules: jsonSchemaMergeRules(draft), mergeCombinarySibling: true, mergeRefSibling: true }) 187 | }) 188 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.types.ts: -------------------------------------------------------------------------------- 1 | import type { JsonPath } from "json-crawl" 2 | 3 | import type { ComapreOptions, CompareResult, CompareRules } from "../types" 4 | import type { jsonSchemaTypes } from "./jsonSchema.consts" 5 | 6 | export type JsonSchemaNodeType = (typeof jsonSchemaTypes)[number] 7 | 8 | export type JsonSchemaRulesOptions = { 9 | version?: "draft-04" | "2020-12" 10 | baseRules?: CompareRules 11 | notMergeAllOf?: boolean 12 | cache?: JsonSchemaComapreCache 13 | } 14 | 15 | export type AllOfNode = { 16 | allOf: any[] 17 | [key: string]: any 18 | } 19 | 20 | export type JsonSchemaCompareOptions = ComapreOptions & { 21 | notMergeAllOf?: boolean // do not merge allOf combiners before compare 22 | } 23 | 24 | export type CompareResultCache = CompareResult & { 25 | path: JsonPath 26 | } 27 | 28 | export type JsonSchemaComapreCache = { 29 | results?: Map 30 | bRefs?: Record 31 | aRefs?: Record 32 | } 33 | -------------------------------------------------------------------------------- /src/jsonSchema/jsonSchema.utils.ts: -------------------------------------------------------------------------------- 1 | import { type RefNode, isRefNode, parseRef, resolvePointer } from "allof-merge" 2 | import type { JsonPath } from "json-crawl" 3 | 4 | import { jsonSchemaTypes, jsonSchemaTypeProps, jsonSchemaValidators } from "./jsonSchema.consts" 5 | import type { AllOfNode, JsonSchemaNodeType } from "./jsonSchema.types" 6 | import { excludeKeys, isNumber, isObject } from "../utils" 7 | 8 | export function isAllOfNode(value: unknown): value is AllOfNode { 9 | return isObject(value) && "allOf" in value && Array.isArray(value.allOf) 10 | } 11 | 12 | export const resolveRefNode = (data: unknown, node: RefNode) => { 13 | const { $ref, ...rest } = node 14 | const _ref = parseRef($ref) 15 | return !_ref.filePath ? resolvePointer(data, _ref.pointer) : undefined 16 | } 17 | 18 | export const isValidType = (maybeType: unknown): maybeType is JsonSchemaNodeType => 19 | typeof maybeType === "string" && jsonSchemaTypes.includes(maybeType as JsonSchemaNodeType) 20 | 21 | export function inferTypes(fragment: unknown): string[] { 22 | if (typeof fragment !== "object" || !fragment) { 23 | return [] 24 | } 25 | 26 | const types: JsonSchemaNodeType[] = [] 27 | for (const type of Object.keys(jsonSchemaTypeProps) as JsonSchemaNodeType[]) { 28 | if (type === "integer") { 29 | continue 30 | } 31 | const props = jsonSchemaValidators[type] 32 | for (const prop of props) { 33 | if (prop in fragment) { 34 | types.push(type) 35 | break 36 | } 37 | } 38 | } 39 | return types 40 | } 41 | 42 | export const isValidSchemaTypes = (types: string[], value: unknown): boolean => { 43 | if (!isObject(value)) { 44 | return false 45 | } 46 | 47 | for (const type of types) { 48 | if ( 49 | !value.type || 50 | (Array.isArray(value.type) && value.type.includes(type)) || 51 | value.type === type || 52 | type === "any" 53 | ) { 54 | return true 55 | } 56 | } 57 | 58 | return false 59 | } 60 | 61 | export function unwrapStringOrNull(value: unknown): string | null { 62 | return typeof value === "string" ? value : null 63 | } 64 | 65 | export function unwrapArrayOrNull(value: unknown): unknown[] | null { 66 | return Array.isArray(value) ? value : null 67 | } 68 | 69 | export const buildPath = (path: JsonPath): string => { 70 | return `/${path.map((i) => String(i).replace(/\//g, "~1")).join("/")}` 71 | } 72 | 73 | export const isCycleRef = ($ref: string, path: string, refs: Record) => { 74 | if (!$ref) { 75 | return false 76 | } 77 | // cycle refs 78 | // 1. $ref already included in refs 79 | if (refs[$ref]?.find((p) => path.startsWith(p))) { 80 | return true 81 | } 82 | // 2. path starts from $ref 83 | if (path.startsWith($ref)) { 84 | return true 85 | } 86 | 87 | return false 88 | } 89 | 90 | export const getCompareId = (beforeRef: string, afterRef: string): string => { 91 | return beforeRef === afterRef ? beforeRef : `${beforeRef}:${afterRef}` 92 | } 93 | 94 | export const resolveRef = (node: unknown, source: unknown) => { 95 | if (!isRefNode(node)) { 96 | return node 97 | } 98 | 99 | return resolveRefNode(source, node) 100 | } 101 | 102 | export const getRef = ($ref?: string) => { 103 | if (!$ref) { 104 | return "" 105 | } 106 | return parseRef($ref).normalized ?? "" 107 | } 108 | 109 | export const mergeCombinarySibling = ( 110 | value: Record, 111 | combiner: string, 112 | allowedSibling: string[] = [], 113 | ) => { 114 | const sibling = { ...value } 115 | const { [combiner]: list, ...allowedProps } = excludeKeys(sibling, [...allowedSibling, combiner]) 116 | 117 | if (!Object.keys(sibling).length) { 118 | return value 119 | } 120 | 121 | return { 122 | ...(Array.isArray(list) ? { [combiner]: list.map((item) => ({ allOf: [item, sibling] })) } : sibling), 123 | ...allowedProps, 124 | } 125 | } 126 | 127 | export const mergeAllOfSibling = (value: Record, allowedSibling: string[] = []) => { 128 | const sibling = { ...value } 129 | const { allOf, ...allowedProps } = excludeKeys(sibling, [...allowedSibling, "allOf"]) 130 | 131 | if (!Object.keys(sibling).length) { 132 | return value 133 | } 134 | 135 | return { 136 | ...(Array.isArray(allOf) ? { allOf: [...allOf, sibling] } : sibling), 137 | ...allowedProps, 138 | } 139 | } 140 | 141 | export const mergeRefSibling = (value: Record, allowedSibling: string[] = []) => { 142 | const sibling = { ...value } 143 | const { $ref, ...allowedProps } = excludeKeys(sibling, [...allowedSibling, "$ref"]) 144 | 145 | if (!Object.keys(sibling).length) { 146 | return value 147 | } 148 | 149 | return { 150 | allOf: [{ $ref }, sibling], 151 | ...allowedProps, 152 | } 153 | } 154 | 155 | export const createEmptyCombiner = ( 156 | value: Record, 157 | combiner: string, 158 | allowedSibling: string[] = [], 159 | ) => { 160 | const sibling = { ...value } 161 | const { [combiner]: list, ...allowedProps } = excludeKeys(sibling, [...allowedSibling]) 162 | 163 | return { 164 | ...allowedProps, 165 | [combiner]: Object.keys(sibling).length ? [sibling] : [], 166 | } 167 | } 168 | 169 | export const getTarget = (path: JsonPath, _prefix = ""): string | undefined => { 170 | let prefix = _prefix 171 | for (let i = 0; i < path.length; i++) { 172 | if (path[i] === "properties" && i < path.length - 1) { 173 | prefix += prefix ? `.${String(path[++i])}` : String(path[++i]) 174 | } else if (path[i] === "additionalProperties") { 175 | prefix += "{.*}" 176 | } else if (path[i] === "patternProperties" && i < path.length - 1) { 177 | prefix += `{${String(path[++i])}}` 178 | } else if (path[i] === "items") { 179 | if (i < path.length - 1 && isNumber(path[i + 1])) { 180 | prefix += `[${path[++i]}]` 181 | } else { 182 | prefix += "[]" 183 | } 184 | } 185 | } 186 | return prefix ? prefix : undefined 187 | } 188 | -------------------------------------------------------------------------------- /src/openapi/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./openapi3.classify" 2 | export * from "./openapi3.compare" 3 | export * from "./openapi3.mapping" 4 | export * from "./openapi3.rules" 5 | export * from "./openapi3.utils" 6 | export * from "./openapi3.transform" 7 | export * from "./openapi3.types" 8 | export * from "./openapi3.schema" 9 | export * from "./openapi3.annotate" 10 | -------------------------------------------------------------------------------- /src/openapi/openapi3.annotate.ts: -------------------------------------------------------------------------------- 1 | import { isParameterSchema, isRequestBodySchema, isResponseSchema } from "./openapi3.utils" 2 | import type { AnnotateHook, ChangeAnnotationResolver } from "../types" 3 | import { createAnnotation, annotationTemplate as t } from "../core" 4 | import { jsonSchemaAnnotations, resolveRef } from "../jsonSchema" 5 | import { getObjectValue } from "../utils" 6 | 7 | const openApiAnnotations = { 8 | requestBodySchema: "{{schemaChange}} in Request Body content ({{contentType}})", 9 | responseSchema: "{{schemaChange}} in Response {{responseCode}} content ({{contentType}})", 10 | parameterSchema: "{{schemaChange}} in {{in}} parameter `{{name}}`", 11 | 12 | add: "[Added] {{text}}", 13 | add_target: "[Added] {{text}} to {{target}}", 14 | remove: "[Removed] {{text}}", 15 | remove_target: "[Removed] {{text}} from {{target}}", 16 | replace: "[Replaced] {{text}}", 17 | replace_target: "[Replaced] {{text}} of {{target}}", 18 | rename: "[Renamed] {{text}}", 19 | rename_target: "[Renamed] {{text}} of {{target}}", 20 | 21 | param: "{{in}} parameter `{{name}}`", 22 | param_required: "required {{in}} parameter `{{name}}`", 23 | status: "{{key}} status", 24 | method: "operation {{method}} {{path}}", 25 | annotation: "annotation ({{key}})", 26 | security: "some security details", 27 | document: "document metadata ({{key}})", 28 | requestBody: "Request Body", 29 | requestBody_contentType: "Request Body content ({{contentType}})", 30 | response: "Response {{responseCode}}", 31 | response_contentType: "Response {{responseCode}} content ({{contentType}})", 32 | contentType: "Content type", 33 | encoding: "Encoding details", 34 | encoding_key: "Encoding details ({{key}})", 35 | } 36 | 37 | export const openApi3AnnotateHook: AnnotateHook = (diff, ctx) => { 38 | let annotate = ctx.rules?.annotate 39 | 40 | if (!annotate || diff.path[0] === "components") { 41 | return "" 42 | } 43 | if (isResponseSchema(diff.path)) { 44 | const schemaChange = createAnnotation(annotate(diff, ctx), jsonSchemaAnnotations) 45 | annotate = () => t("responseSchema", { schemaChange, responseCode: diff.path[4], contentType: diff.path[6] }) 46 | } else if (isRequestBodySchema(diff.path)) { 47 | const schemaChange = createAnnotation(annotate(diff, ctx), jsonSchemaAnnotations) 48 | annotate = () => t("requestBodySchema", { schemaChange, contentType: diff.path[5] }) 49 | } else if (isParameterSchema(diff.path)) { 50 | const schemaChange = createAnnotation(annotate(diff, ctx), jsonSchemaAnnotations) 51 | const { root } = diff.action === "add" ? ctx.after : ctx.before 52 | const paramPath = diff.path.slice(0, diff.path[2] === "parameters" ? 4 : 5) 53 | const node = getObjectValue(root, ...paramPath) 54 | annotate = () => t("parameterSchema", { ...resolveRef(node, root), schemaChange }) 55 | } 56 | 57 | return createAnnotation(annotate(diff, ctx), openApiAnnotations) 58 | } 59 | 60 | export const pathMethodChangeAnnotation: ChangeAnnotationResolver = ({ action, path }) => { 61 | return t(action, { text: t("method", { path: path[1], method: String(path[2]).toUpperCase() }) }) 62 | } 63 | 64 | export const documentChangeAnnotation: ChangeAnnotationResolver = ({ action, path }) => { 65 | return t(action, { text: t("document", { key: path.join(".") }) }) 66 | } 67 | 68 | export const operationSecurityChangeAnnotation: ChangeAnnotationResolver = ({ action }) => { 69 | return t(action, { text: t("security") }) 70 | } 71 | 72 | export const requestBodyChangeAnnotation: ChangeAnnotationResolver = ({ path, action }) => { 73 | const key = path[path.length - 1] 74 | 75 | if (key === "required" || key === "deprecated") { 76 | return t(action, { text: t("status", { key }), target: t("requestBody") }) 77 | } 78 | 79 | return t(action, { text: t("annotation", { key }), target: t("requestBody") }) 80 | } 81 | 82 | export const responseChangeAnnotation: ChangeAnnotationResolver = ({ path, action }) => { 83 | const responseCode = path[4] 84 | const key = path[path.length - 1] 85 | 86 | if (responseCode === key) { 87 | return t(action, { text: t("response", { responseCode }) }) 88 | } 89 | 90 | return t(action, { text: t("annotation", { key }), target: t("response", { responseCode }) }) 91 | } 92 | 93 | export const contentChangeAnnotation: ChangeAnnotationResolver = ({ path, action }, ctx) => { 94 | const contentType = isResponseSchema(path) ? path[6] : path[5] 95 | const responseCode = isResponseSchema(path) ? path[4] : undefined 96 | const target = isResponseSchema(path) 97 | ? t("response", { contentType, responseCode }) 98 | : t("requestBody", { contentType }) 99 | const key = path[path.length - 1] 100 | 101 | if (contentType && contentType !== key) { 102 | return t(action, { text: t("annotation", { key }), target }) 103 | } 104 | 105 | return t(action, { text: t("contentType"), target }) 106 | } 107 | 108 | export const encodingChangeAnnotation: ChangeAnnotationResolver = ({ path, action }, ctx) => { 109 | const contentType = isResponseSchema(path) ? path[6] : path[5] 110 | const responseCode = isResponseSchema(path) ? path[4] : undefined 111 | const target = isResponseSchema(path) 112 | ? t("response", { contentType, responseCode }) 113 | : t("requestBody", { contentType }) 114 | 115 | const encodingPath = isResponseSchema(path) ? path.slice(8) : path.slice(7) 116 | const key = encodingPath.join(".") 117 | 118 | return t(action, { text: t("encoding", { key }), target }) 119 | } 120 | 121 | export const operationChangeAnnotation: ChangeAnnotationResolver = ({ path, action }, ctx) => { 122 | const { key } = action === "add" ? ctx.after : ctx.before 123 | 124 | if (key === "deprecated") { 125 | if (ctx.after.value) { 126 | return t("add", { text: t("status", { key }) }) 127 | } 128 | if (ctx.before.value) { 129 | return t("remove", { text: t("status", { key }) }) 130 | } 131 | return 132 | } 133 | 134 | if (key === "requestBody") { 135 | return t(action, { text: t(key) }) 136 | } 137 | 138 | if (typeof key === "number") { 139 | const { value } = action === "add" ? ctx.after : ctx.before 140 | return t(action, { text: t("annotation", { key: `${path[path.length - 2]}: ${value}` }) }) 141 | } 142 | 143 | return t(action, { text: t("annotation", { key }) }) 144 | } 145 | 146 | export const parameterChangeAnnotation: ChangeAnnotationResolver = ({ action }, ctx) => { 147 | const { path, root, key } = action === "add" ? ctx.after : ctx.before 148 | 149 | const paramPath = path.slice(0, path[2] === "parameters" ? 4 : 5) 150 | const node = getObjectValue(root, ...paramPath) 151 | const param = resolveRef(node, root) 152 | 153 | if (key === "required") { 154 | return t(action, { text: t("status", { key }), target: t("param", { ...param, requred: false }) }) 155 | } 156 | if (key === "deprecated") { 157 | return t(action, { text: t("status", { key }), target: t("param", param) }) 158 | } 159 | return t(action, { text: t("param", param) }) 160 | } 161 | -------------------------------------------------------------------------------- /src/openapi/openapi3.classify.ts: -------------------------------------------------------------------------------- 1 | import { getParentContext, annotation, breaking, nonBreaking, unclassified } from "../core" 2 | import { getKeyValue, isNotEmptyArray, isExist } from "../utils" 3 | import { emptySecurity, includeSecurity } from "./openapi3.utils" 4 | import { breakingIfAfterTrue } from "../jsonSchema" 5 | import type { ClassifyRule } from "../types" 6 | 7 | export const parameterStyleClassifyRule: ClassifyRule = [ 8 | ({ after }) => (after.value === "form" ? annotation : breaking), 9 | ({ before }) => (before.value === "form" ? annotation : breaking), 10 | breaking, 11 | ] 12 | 13 | export const parameterExplodeClassifyRule: ClassifyRule = [ 14 | ({ after }) => 15 | (after.value && getKeyValue(after.parent, "style") === "form") || 16 | (!after.value && getKeyValue(after.parent, "style") !== "form") 17 | ? annotation 18 | : breaking, 19 | ({ before }) => 20 | (before.value && getKeyValue(before.parent, "style") === "form") || 21 | (!before.value && getKeyValue(before.parent, "style") !== "form") 22 | ? annotation 23 | : breaking, 24 | breaking, 25 | ] 26 | 27 | export const parameterNameClassifyRule: ClassifyRule = [ 28 | nonBreaking, 29 | breaking, 30 | ({ before }) => (getKeyValue(before.parent, "in") === "path" ? nonBreaking : breaking), 31 | ] 32 | 33 | export const parameterRequiredClassifyRule: ClassifyRule = [ 34 | breaking, 35 | nonBreaking, 36 | (ctx) => (getKeyValue(ctx.after.parent, "schema", "default") ? nonBreaking : breakingIfAfterTrue(ctx)), 37 | ] 38 | 39 | export const paramSchemaTypeClassifyRule: ClassifyRule = [ 40 | breaking, 41 | nonBreaking, 42 | ({ before, after }) => { 43 | const paramContext = getParentContext(before, "") 44 | const paramStyle = getKeyValue(paramContext?.value, "style") ?? "form" 45 | if (getKeyValue(paramContext?.value, "in") === "query" && paramStyle === "form") { 46 | return before.value === "object" || before.value === "array" || after.value === "object" ? breaking : nonBreaking 47 | } 48 | return breaking 49 | }, 50 | ] 51 | 52 | export const paramClassifyRule: ClassifyRule = [ 53 | ({ after }) => 54 | getKeyValue(after.value, "required") && !isExist(getKeyValue(after.value, "schema", "default")) 55 | ? breaking 56 | : nonBreaking, 57 | breaking, 58 | unclassified, 59 | ] 60 | 61 | export const globalSecurityClassifyRule: ClassifyRule = [ 62 | ({ after }) => (!emptySecurity(after.value) ? breaking : nonBreaking), 63 | nonBreaking, 64 | ({ after, before }) => 65 | includeSecurity(after.value, before.value) || emptySecurity(after.value) ? nonBreaking : breaking, 66 | ] 67 | 68 | export const globalSecurityItemClassifyRule: ClassifyRule = [ 69 | ({ before }) => (isNotEmptyArray(before.parent) ? nonBreaking : breaking), 70 | ({ after }) => (isNotEmptyArray(after.parent) ? nonBreaking : breaking), 71 | ({ after, before }) => 72 | includeSecurity(after.parent, before.parent) || emptySecurity(after.value) ? nonBreaking : breaking, 73 | ] 74 | 75 | export const operationSecurityClassifyRule: ClassifyRule = [ 76 | ({ before, after }) => 77 | emptySecurity(after.value) || includeSecurity(after.value, getKeyValue(before.root, "security")) 78 | ? nonBreaking 79 | : breaking, 80 | ({ before, after }) => (includeSecurity(getKeyValue(after.root, "security"), before.value) ? nonBreaking : breaking), 81 | ({ before, after }) => 82 | includeSecurity(after.value, before.value) || emptySecurity(after.value) ? nonBreaking : breaking, 83 | ] 84 | 85 | export const operationSecurityItemClassifyRule: ClassifyRule = [ 86 | ({ before }) => (isNotEmptyArray(before.parent) ? nonBreaking : breaking), 87 | ({ after }) => (isNotEmptyArray(after.parent) ? breaking : nonBreaking), 88 | ({ before, after }) => 89 | includeSecurity(after.parent, before.parent) || emptySecurity(after.value) ? nonBreaking : breaking, 90 | ] 91 | -------------------------------------------------------------------------------- /src/openapi/openapi3.compare.ts: -------------------------------------------------------------------------------- 1 | import type { OpenApiComapreOptions } from "./openapi3.types" 2 | import type { CompareResult, SourceContext } from "../types" 3 | import { openApi3AnnotateHook } from "./openapi3.annotate" 4 | import { getMaxOpenApiVersion } from "./openapi3.utils" 5 | import { openapi3Rules } from "./openapi3.rules" 6 | import { compare } from "../core" 7 | 8 | export const compareOpenApi = ( 9 | before: unknown, 10 | after: unknown, 11 | options: OpenApiComapreOptions = {}, 12 | context: SourceContext = {}, 13 | ): CompareResult => { 14 | const { notMergeAllOf } = options 15 | 16 | // set default options 17 | const _options = { 18 | ...options, 19 | rules: options.rules ?? openapi3Rules({ notMergeAllOf }), 20 | version: getMaxOpenApiVersion(before, after), 21 | annotateHook: options.annotateHook ?? openApi3AnnotateHook, 22 | } 23 | 24 | return compare(before, after, _options, context) 25 | } 26 | -------------------------------------------------------------------------------- /src/openapi/openapi3.mapping.ts: -------------------------------------------------------------------------------- 1 | import type { MapKeysResult, MappingResolver } from "../types" 2 | import { getStringValue, objectKeys } from "../utils" 3 | import { mapPathParams } from "./openapi3.utils" 4 | import { resolveRef } from "../jsonSchema" 5 | 6 | export const pathMappingResolver: MappingResolver = (before, after) => { 7 | const result: MapKeysResult = { added: [], removed: [], mapped: {} } 8 | 9 | const beforeKeys = objectKeys(before) 10 | const _beforeKeys = beforeKeys.map((key) => key.replace(/\{.*?\}/g, "*")) 11 | const afterKeys = objectKeys(after) 12 | const _afterKeys = afterKeys.map((key) => key.replace(/\{.*?\}/g, "*")) 13 | 14 | const mappedIndex = new Set(afterKeys.keys()) 15 | 16 | for (let i = 0; i < beforeKeys.length; i++) { 17 | const _afterIndex = _afterKeys.indexOf(_beforeKeys[i]) 18 | 19 | if (_afterIndex < 0) { 20 | // removed item 21 | result.removed.push(beforeKeys[i]) 22 | } else { 23 | // mapped items 24 | result.mapped[beforeKeys[i]] = afterKeys[_afterIndex] 25 | mappedIndex.delete(_afterIndex) 26 | } 27 | } 28 | 29 | // added items 30 | mappedIndex.forEach((i) => result.added.push(afterKeys[i])) 31 | 32 | return result 33 | } 34 | 35 | export const paramMappingResolver: MappingResolver = (before, after, ctx) => { 36 | const result: MapKeysResult = { added: [], removed: [], mapped: {} } 37 | 38 | const pathParamMapping = mapPathParams(ctx) 39 | const mappedIndex = new Set(after.keys()) 40 | const _before = before.map((b) => resolveRef(b, ctx.before.root)) 41 | const _after = after.map((a) => resolveRef(a, ctx.after.root)) 42 | 43 | for (let i = 0; i < _before.length; i++) { 44 | const beforeIn = getStringValue(_before[i], "in") 45 | const beforeName = getStringValue(_before[i], "name") ?? "" 46 | 47 | const _afterIndex = _after.findIndex((a) => { 48 | const afterIn = getStringValue(a, "in") 49 | const afterName = getStringValue(a, "name") ?? "" 50 | 51 | // use extra mapping logic for path parameters 52 | return ( 53 | beforeIn === afterIn && 54 | (beforeName === afterName || (beforeIn === "path" && pathParamMapping[beforeName] === afterName)) 55 | ) 56 | }) 57 | 58 | if (_afterIndex < 0) { 59 | // removed item 60 | result.removed.push(i) 61 | } else { 62 | // mapped items 63 | result.mapped[i] = _afterIndex 64 | mappedIndex.delete(_afterIndex) 65 | } 66 | } 67 | 68 | // added items 69 | mappedIndex.forEach((i) => result.added.push(i)) 70 | return result 71 | } 72 | 73 | export const contentMediaTypeMappingResolver: MappingResolver = (before, after) => { 74 | const result: MapKeysResult = { added: [], removed: [], mapped: {} } 75 | 76 | const beforeKeys = objectKeys(before) 77 | const _beforeKeys = beforeKeys.map((key) => key.split(";")[0] ?? "") 78 | const afterKeys = objectKeys(after) 79 | const _afterKeys = afterKeys.map((key) => key.split(";")[0] ?? "") 80 | 81 | const mappedIndex = new Set(afterKeys.keys()) 82 | 83 | for (let i = 0; i < beforeKeys.length; i++) { 84 | const _afterIndex = _afterKeys.findIndex((key) => { 85 | const [afterType, afterSubType] = key.split("/") 86 | const [beforeType, beforeSubType] = _beforeKeys[i].split("/") 87 | 88 | if (afterType !== beforeType && afterType !== "*" && beforeType !== "*") { 89 | return false 90 | } 91 | if (afterSubType !== beforeSubType && afterSubType !== "*" && beforeSubType !== "*") { 92 | return false 93 | } 94 | return true 95 | }) 96 | 97 | if (_afterIndex < 0 || !mappedIndex.has(_afterIndex)) { 98 | // removed item 99 | result.removed.push(beforeKeys[i]) 100 | } else { 101 | // mapped items 102 | result.mapped[beforeKeys[i]] = afterKeys[_afterIndex] 103 | mappedIndex.delete(_afterIndex) 104 | } 105 | } 106 | 107 | // added items 108 | mappedIndex.forEach((i) => result.added.push(afterKeys[i])) 109 | 110 | return result 111 | } 112 | -------------------------------------------------------------------------------- /src/openapi/openapi3.schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | booleanClassifier, 3 | jsonSchemaAllowedSibling, 4 | jsonSchemaKeyChange, 5 | jsonSchemaRules, 6 | schemaAnnotationChange, 7 | transformJsonSchema, 8 | transformJsonSchemaCombiners, 9 | } from "../jsonSchema" 10 | import { reverseClassifyRuleTransformer, transformComapreRules, allAnnotation, allUnclassified } from "../core" 11 | import type { OpenApi3SchemaRulesOptions } from "./openapi3.types" 12 | import { transformOpenApiSchema } from "./openapi3.transform" 13 | import type { CompareRules } from "../types" 14 | 15 | export const openApiSchemaRules = (options: OpenApi3SchemaRulesOptions = {}): CompareRules => { 16 | const version = options.version === "3.0.x" ? "draft-04" : "2020-12" 17 | 18 | const schemaRules = jsonSchemaRules({ 19 | baseRules: { 20 | transform: [ 21 | transformJsonSchemaCombiners([...jsonSchemaAllowedSibling, "discriminator"]), 22 | transformJsonSchema(version), 23 | transformOpenApiSchema, 24 | ], 25 | // openapi extentions 26 | "/nullable": { $: booleanClassifier, annotate: jsonSchemaKeyChange }, 27 | "/discriminator": { $: allUnclassified, annotate: schemaAnnotationChange }, 28 | "/example": { $: allAnnotation, annotate: schemaAnnotationChange }, 29 | "/externalDocs": { 30 | $: allAnnotation, 31 | annotate: schemaAnnotationChange, 32 | "/*": { $: allAnnotation }, 33 | }, 34 | "/xml": {}, 35 | }, 36 | notMergeAllOf: options.notMergeAllOf, 37 | version, 38 | }) 39 | 40 | return options.response ? transformComapreRules(schemaRules, reverseClassifyRuleTransformer) : schemaRules 41 | } 42 | -------------------------------------------------------------------------------- /src/openapi/openapi3.transform.ts: -------------------------------------------------------------------------------- 1 | import type { ComapreContext, CompareTransformResolver } from "../types" 2 | import { isKey, isObject, objectKeys, setKeyValue } from "../utils" 3 | import { pathMappingResolver } from "./openapi3.mapping" 4 | import { compareTransformationFactory } from "../core" 5 | import { getDefaultStyle } from "./openapi3.utils" 6 | 7 | export const transformPathItems = compareTransformationFactory((value) => { 8 | if (!isObject(value)) { 9 | return value 10 | } 11 | 12 | const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] 13 | 14 | const _value = objectKeys(value).reduce((res, key) => { 15 | if (methods.includes(key)) { 16 | return res 17 | } 18 | res[key] = value[key] 19 | return res 20 | }, {} as any) 21 | 22 | if (!objectKeys(_value).length) { 23 | return value 24 | } 25 | 26 | const result: Record = {} 27 | 28 | for (const method of methods) { 29 | if (!isKey(value, method) || typeof value[method] !== "object" || !value[method]) { 30 | continue 31 | } 32 | const data = { ...(value[method] as any) } 33 | 34 | const { parameters, servers, ...rest } = _value 35 | 36 | // copy path parameters to all methods 37 | if (parameters && Array.isArray(parameters)) { 38 | if ("parameters" in data && Array.isArray(data.parameters)) { 39 | data.parameters = [...data.parameters, ...parameters] 40 | } else { 41 | data.parameters = parameters 42 | } 43 | } 44 | 45 | // copy servers to all methods 46 | if (servers && Array.isArray(servers)) { 47 | if ("servers" in data && Array.isArray(data.servers)) { 48 | data.servers = [...data.servers, ...servers] 49 | } else { 50 | data.servers = servers 51 | } 52 | } 53 | 54 | // copy summary/description and rest to all methods 55 | for (const key of objectKeys(rest)) { 56 | if (isKey(data, key)) { 57 | continue 58 | } 59 | data[key] = rest[key] 60 | } 61 | 62 | result[method] = data 63 | } 64 | 65 | return result 66 | }) 67 | 68 | export const transformOperation = compareTransformationFactory((value, other) => { 69 | if (!isObject(value) || !isObject(other)) { 70 | return value 71 | } 72 | const { deprecated, ...result }: any = { ...value } 73 | 74 | // add empty tags array 75 | if (!("tags" in value) && "tags" in other) { 76 | result.tags = [] 77 | } 78 | 79 | // remvoe deprecated: false 80 | if (deprecated) { 81 | result.deprecated = deprecated 82 | } 83 | 84 | return result 85 | }) 86 | 87 | export const transformPaths: CompareTransformResolver = (before, after) => { 88 | if (!isObject(before) || !isObject(after)) { 89 | return [before, after] 90 | } 91 | 92 | // add empty paths (diff should be in methods) 93 | const { added, removed } = pathMappingResolver(before, after, {} as ComapreContext) 94 | return [ 95 | added.reduce((obj, key) => setKeyValue(obj, key, { [key]: {} }), { ...before }), 96 | removed.reduce((obj, key) => setKeyValue(obj, key, { [key]: {} }), { ...after }), 97 | ] 98 | } 99 | 100 | export const transformParameterItem = compareTransformationFactory((value, other) => { 101 | if (!isObject(value) || !isObject(other)) { 102 | return value 103 | } 104 | 105 | const { deprecated, required, ...result }: any = { ...value } 106 | 107 | // set default value for style 108 | if ("in" in value && !("style" in value) && "style" in other) { 109 | const style = getDefaultStyle(value.in) 110 | if (style) { 111 | result.style = style 112 | } 113 | } 114 | 115 | // set default value for explode 116 | if ("style" in result && "explode" in value && "explode" in other) { 117 | if (result.style === "form") { 118 | result.explode = true 119 | } 120 | } 121 | 122 | // remove deprecated: false 123 | if (deprecated) { 124 | result.deprecated = deprecated 125 | } 126 | 127 | // remove required: false 128 | if (required) { 129 | result.required = required 130 | } 131 | 132 | return result 133 | }) 134 | 135 | export const transformOpenApiSchema = compareTransformationFactory((value, other) => { 136 | if (!isObject(value) || !isObject(other)) { 137 | return value 138 | } 139 | 140 | // 1. convert discriminator to consts 141 | // 2. add custom tag to descriminator property 142 | // if (("discriminator" in value && isOneOfNode(value)) || isAnyOfNode(value)) { 143 | // const { discriminator, ...rest } = value 144 | 145 | // if ( 146 | // typeof discriminator !== "object" || 147 | // !discriminator || 148 | // Array.isArray(discriminator) || 149 | // !("propertyName" in discriminator) 150 | // ) { 151 | // return rest 152 | // } 153 | 154 | // const prop = discriminator.propertyName 155 | // const mapping: Record = discriminator.mapping ?? {} 156 | // const refs = Object.entries(mapping).reduce((res, [key, $ref]) => (res[$ref] = key), {} as any) 157 | 158 | // const transformCombinary = (item: unknown) => 159 | // isRefNode(item) && item.$ref in refs 160 | // ? { ...item, properties: { [prop]: { ...(item.properties ?? {}), const: refs[item.$ref] } } } 161 | // : item 162 | 163 | // if (isAnyOfNode(value)) { 164 | // return { ...rest, anyOf: value.anyOf.map(transformCombinary) } 165 | // } else if (isOneOfNode(value)) { 166 | // return { ...rest, oneOf: value.oneOf.map(transformCombinary) } 167 | // } 168 | // } 169 | return value 170 | }) 171 | -------------------------------------------------------------------------------- /src/openapi/openapi3.types.ts: -------------------------------------------------------------------------------- 1 | import type { ComapreOptions } from "../types" 2 | 3 | export type OpenApi3RulesOptions = { 4 | version?: "3.0.x" | "3.1.x" 5 | notMergeAllOf?: boolean 6 | } 7 | 8 | export type OpenApi3SchemaRulesOptions = OpenApi3RulesOptions & { 9 | response?: boolean 10 | } 11 | 12 | export type OpenApiComapreOptions = ComapreOptions & Omit 13 | -------------------------------------------------------------------------------- /src/openapi/openapi3.utils.ts: -------------------------------------------------------------------------------- 1 | import { type JsonPath, isObject } from "json-crawl" 2 | 3 | import type { ComapreContext } from "../types" 4 | 5 | export const emptySecurity = (value?: unknown) => { 6 | if (!Array.isArray(value)) { 7 | return false 8 | } 9 | 10 | return !!value && (value.length === 0 || (value.length === 1 && Object.keys(value[0]).length === 0)) 11 | } 12 | 13 | export const includeSecurity = (value: unknown = [], items: unknown = []) => { 14 | if (!Array.isArray(value) || !Array.isArray(items)) { 15 | return false 16 | } 17 | 18 | // TODO match security schema 19 | const valueSet = new Set(value.map((item) => Object.keys(item)[0])) 20 | 21 | for (const item of items) { 22 | if (!valueSet.has(Object.keys(item)[0])) { 23 | return false 24 | } 25 | } 26 | 27 | return true 28 | } 29 | 30 | export const mapPathParams = ({ before, after }: ComapreContext): Record => { 31 | if (typeof before.path[1] !== "string" || typeof after.path[1] !== "string") { 32 | return {} 33 | } 34 | 35 | const beforeParams = [...before.path[1].matchAll(/\{(.*?)\}/g)].map((arr) => arr.pop()) as string[] 36 | const afterParams = [...after.path[1].matchAll(/\{(.*?)\}/g)].map((arr) => arr.pop()) as string[] 37 | 38 | const result: Record = {} 39 | for (let i = 0; i < beforeParams.length && i < afterParams.length; i++) { 40 | result[beforeParams[i]] = afterParams[i] 41 | } 42 | 43 | return result 44 | } 45 | 46 | export const getDefaultStyle = (type: unknown) => { 47 | switch (type) { 48 | case "query": 49 | return "form" 50 | case "cookie": 51 | return "form" 52 | case "path": 53 | return "simple" 54 | case "header": 55 | return "simple" 56 | } 57 | } 58 | 59 | export const isResponseSchema = (path: JsonPath) => { 60 | return path[3] === "responses" && path[7] === "schema" 61 | } 62 | 63 | export const isRequestBodySchema = (path: JsonPath) => { 64 | return path[3] === "requestBody" && path[6] === "schema" 65 | } 66 | 67 | export const isParameterSchema = (path: JsonPath) => { 68 | return (path[2] === "parameters" && path[4] === "schema") || (path[3] === "parameters" && path[5] === "schema") 69 | } 70 | 71 | export const getMaxOpenApiVersion = (before: unknown, after: unknown) => { 72 | if (!isObject(before) || !isObject(after) || !("openapi" in before) || !("openapi" in after)) { 73 | return 74 | } 75 | 76 | const version = before.openapi > after.openapi ? before.openapi : after.openapi 77 | 78 | return version.startsWith("3.1") ? "3.1.x" : "3.0.x" 79 | } 80 | -------------------------------------------------------------------------------- /src/types/compare.ts: -------------------------------------------------------------------------------- 1 | import type { JsonPath, SyncCrawlHook } from "json-crawl" 2 | 3 | import type { ComapreContext, CompareRule, CompareRules } from "./rules" 4 | import type { ClassifierType, DiffAction } from "../core/constants" 5 | 6 | export type ActionType = keyof typeof DiffAction 7 | export type DiffType = (typeof ClassifierType)[keyof typeof ClassifierType] 8 | 9 | export type Diff = { 10 | type: DiffType 11 | action: ActionType 12 | path: JsonPath 13 | before?: any 14 | after?: any 15 | description?: string 16 | } 17 | 18 | export type DiffMeta = { 19 | action: ActionType 20 | type: DiffType 21 | replaced?: any 22 | } 23 | 24 | export interface CompareResult { 25 | diffs: Diff[] 26 | merged: any 27 | rootMergeMeta?: MergeMeta 28 | } 29 | 30 | export type MergeMeta = DiffMeta | MergeArrayMeta 31 | export type MergeArrayMeta = { array: Record } 32 | 33 | export type SourceContext = { 34 | before?: { 35 | source: unknown 36 | jsonPath: JsonPath 37 | } 38 | after?: { 39 | source: unknown 40 | jsonPath: JsonPath 41 | } 42 | } 43 | 44 | export type ComapreOptions = { 45 | // custom rules for compare 46 | rules?: CompareRules 47 | 48 | // metakey for merge changes 49 | metaKey?: string | symbol 50 | // add changes to arrays via metakey 51 | arrayMeta?: boolean 52 | // custom format hook 53 | annotateHook?: AnnotateHook 54 | 55 | externalSources?: { 56 | // external $ref sources 57 | before?: Record 58 | after?: Record 59 | } 60 | } 61 | 62 | export type CompareEngine = ( 63 | before: unknown, 64 | after: unknown, 65 | options?: ComapreOptions, 66 | context?: SourceContext, 67 | ) => CompareResult 68 | 69 | export type AnnotateHook = (diff: Diff, ctx: ComapreContext) => string 70 | export type NodeRoot = { "#": any } 71 | export type KeyMapping = Record 72 | 73 | export interface MergeState { 74 | keyMap: KeyMapping // parent keys mappings 75 | aPath: JsonPath // after path from root 76 | aNode: JsonNode // after Node 77 | bPath: JsonPath // before path from root 78 | bNode: JsonNode // before Node 79 | mNode: any // merged Node 80 | parentMeta: MergeMetaRecord // parent merge meta 81 | root: { 82 | before: NodeRoot // before root Node 83 | after: NodeRoot // after root Node 84 | merged: JsonNode // merged root Node 85 | } 86 | } 87 | 88 | export type MergeMetaRecord = Record 89 | 90 | export type JsonNode = T extends string 91 | ? Record 92 | : Array 93 | 94 | export interface DiffFactory { 95 | added: (path: JsonPath, after: unknown, ctx: ComapreContext) => Diff 96 | removed: (path: JsonPath, before: unknown, ctx: ComapreContext) => Diff 97 | replaced: (path: JsonPath, before: unknown, after: unknown, ctx: ComapreContext) => Diff 98 | renamed: (path: JsonPath, before: unknown, after: unknown, ctx: ComapreContext) => Diff 99 | } 100 | 101 | export interface MergeFactoryResult { 102 | diffs: Diff[] 103 | hook: SyncCrawlHook 104 | } 105 | 106 | export interface ContextInput extends MergeState { 107 | before: any 108 | after: any 109 | bPath: JsonPath 110 | akey: string | number 111 | bkey: string | number 112 | rules: CompareRules 113 | } 114 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./rules" 2 | export * from "./compare" 3 | -------------------------------------------------------------------------------- /src/types/rules.ts: -------------------------------------------------------------------------------- 1 | import type { CrawlRules, CrawlRulesFunc, JsonPath } from "json-crawl" 2 | 3 | import type { Diff, DiffType, ComapreOptions, CompareResult } from "./compare" 4 | 5 | export type DiffTypeClassifier = (ctx: ComapreContext) => DiffType 6 | 7 | export type ClassifyRule = 8 | | [AddDiffType, RemoveDiffType, ReplaceDiffType] 9 | | [AddDiffType, RemoveDiffType, ReplaceDiffType, ReversedAddDiffType, ReversedRemoveDiffType, ReversedReplaceDiffType] 10 | 11 | export type AddDiffType = RuleDiffType 12 | export type RemoveDiffType = RuleDiffType 13 | export type ReplaceDiffType = RuleDiffType 14 | 15 | // Reversed DiffType uses to specify rule for reverse case 16 | export type ReversedAddDiffType = RuleDiffType 17 | export type ReversedRemoveDiffType = RuleDiffType 18 | export type ReversedReplaceDiffType = RuleDiffType 19 | 20 | export type RuleDiffType = DiffType | DiffTypeClassifier 21 | 22 | export type NodeContext = { 23 | path: JsonPath 24 | key: string | number 25 | value: unknown 26 | parent?: unknown 27 | root: unknown 28 | } 29 | 30 | export type ComapreContext = { 31 | before: NodeContext 32 | after: NodeContext 33 | rules: CompareRules 34 | options: ComapreOptions 35 | } 36 | 37 | export type CompareResolver = (ctx: ComapreContext) => CompareResult | undefined 38 | 39 | export type TransformResolver = (value: T, other: T) => T 40 | export type CompareTransformResolver = (before: T, after: T) => [T, T] 41 | 42 | export type MappingResolver = T extends string ? MappingObjectResolver : MappingArrayResolver 43 | export type MappingObjectResolver = ( 44 | before: Record, 45 | after: Record, 46 | ctx: ComapreContext, 47 | ) => MapKeysResult 48 | export type MappingArrayResolver = ( 49 | before: Array, 50 | after: Array, 51 | ctx: ComapreContext, 52 | ) => MapKeysResult 53 | 54 | export interface AnnotateTemplate { 55 | template: string 56 | params?: { [key: string]: AnnotateTemplate | string | number | undefined } 57 | } 58 | 59 | export type ChangeAnnotationResolver = (diff: Diff, ctx: ComapreContext) => AnnotateTemplate | undefined 60 | 61 | export type CompareRule = { 62 | // classifier for current node 63 | $?: ClassifyRule 64 | 65 | // compare handler for current node 66 | compare?: CompareResolver 67 | 68 | // transformations 69 | transform?: CompareTransformResolver[] 70 | 71 | // key mapping rules 72 | mapping?: MappingResolver 73 | 74 | // resolver for annotation template 75 | annotate?: ChangeAnnotationResolver 76 | 77 | // skip comparison, use before value as merge result 78 | skip?: boolean 79 | } 80 | 81 | export type CompareRules = CrawlRules 82 | export type CompareRulesFunc = CrawlRulesFunc 83 | 84 | export interface MapKeysResult { 85 | added: Array 86 | removed: Array 87 | mapped: Record 88 | } 89 | 90 | export type CompareRulesTransformer = (rules: CompareRules) => CompareRules 91 | export type ClassifyRuleTransformer = ( 92 | type: DiffType, 93 | ctx: ComapreContext, 94 | action: "add" | "remove" | "replace", 95 | ) => DiffType 96 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { JsonPath } from "json-crawl" 2 | 3 | export const isKey = (x: T, k: PropertyKey): k is keyof T => { 4 | return k in x 5 | } 6 | 7 | export const isObject = (value: unknown): value is Record => { 8 | return typeof value === "object" && value !== null 9 | } 10 | 11 | export const isArray = (value: unknown): value is Array => { 12 | return Array.isArray(value) 13 | } 14 | 15 | export const isNotEmptyArray = (value: unknown): boolean => { 16 | return !!(Array.isArray(value) && value.length) 17 | } 18 | 19 | export const isExist = (value: unknown): boolean => { 20 | return typeof value !== "undefined" 21 | } 22 | 23 | export const isString = (value: unknown): value is string => { 24 | return typeof value === "string" 25 | } 26 | 27 | export const isNumber = (value: unknown): value is number => { 28 | return typeof value === "number" || (isString(value) && !Number.isNaN(+value)) 29 | } 30 | 31 | export const isFunc = (value: unknown): value is Function => { 32 | return typeof value === "function" 33 | } 34 | 35 | export const typeOf = (value: unknown): string => { 36 | if (Array.isArray(value)) { 37 | return "array" 38 | } 39 | return value == null ? "null" : typeof value 40 | } 41 | 42 | export const objectKeys = (value: T): (keyof T)[] => { 43 | return Object.keys(value) as (keyof T)[] 44 | } 45 | 46 | export const setKeyValue = ( 47 | obj: Record, 48 | key: string | number, 49 | value: unknown, 50 | ): Record => { 51 | obj[key] = value 52 | return obj 53 | } 54 | 55 | export const filterObj = ( 56 | value: T, 57 | func: (key: number | string | symbol, obj: T) => boolean, 58 | ): Partial => { 59 | const result: Partial = {} 60 | for (const key of objectKeys(value)) { 61 | if (!func(key, value)) { 62 | continue 63 | } 64 | result[key] = value[key] 65 | } 66 | return result 67 | } 68 | 69 | export const excludeKeys = (value: T, keys: Array): Partial => { 70 | const excluded: Partial = {} 71 | for (const key of keys) { 72 | if (key in value) { 73 | excluded[key] = value[key] 74 | delete value[key] 75 | } 76 | } 77 | return excluded 78 | } 79 | 80 | export const getKeyValue = (obj: unknown, ...path: JsonPath): unknown | undefined => { 81 | let value: unknown = obj 82 | for (const key of path) { 83 | if (Array.isArray(value) && typeof +key === "number" && value.length < +key) { 84 | value = value[+key] 85 | } else if (isObject(value) && key in value) { 86 | value = value[key] 87 | } else { 88 | return 89 | } 90 | if (value === undefined) { 91 | return 92 | } 93 | } 94 | return value 95 | } 96 | 97 | export const getStringValue = (obj: unknown, ...path: JsonPath): string | undefined => { 98 | const value = getKeyValue(obj, ...path) 99 | return typeof value === "string" ? value : undefined 100 | } 101 | 102 | export const getObjectValue = (obj: unknown, ...path: JsonPath): Record | undefined => { 103 | const value = getKeyValue(obj, ...path) 104 | return isObject(value) ? value : undefined 105 | } 106 | 107 | export const getArrayValue = (obj: unknown, ...path: JsonPath): Array | undefined => { 108 | const value = getKeyValue(obj, ...path) 109 | return Array.isArray(value) ? value : undefined 110 | } 111 | 112 | export const getNumberValue = (obj: unknown, ...path: JsonPath): number | undefined => { 113 | const value = getKeyValue(obj, ...path) 114 | return typeof value === "number" ? value : typeof value === "string" && +value ? +value : undefined 115 | } 116 | 117 | export const getBooleanValue = (obj: unknown, ...path: JsonPath): boolean | undefined => { 118 | const value = getKeyValue(obj, ...path) 119 | return typeof value === "boolean" 120 | ? value 121 | : typeof value === "string" && (value === "true" || value === "false") 122 | ? Boolean(value) 123 | : undefined 124 | } 125 | 126 | export const joinPath = (base: JsonPath, ...items: JsonPath[]): JsonPath => { 127 | const result = [...base] 128 | for (const item of items) { 129 | for (const step of item) { 130 | if (step === "") { 131 | result.pop() 132 | } else { 133 | result.push(step) 134 | } 135 | } 136 | } 137 | return result 138 | } 139 | -------------------------------------------------------------------------------- /test/annotate.test.ts: -------------------------------------------------------------------------------- 1 | import { createAnnotation, annotationTemplate as t } from "../src" 2 | 3 | describe("", () => { 4 | const dict = { 5 | foo: "Hello {{world}}", 6 | foo_name: "Hello {{name}}", 7 | } 8 | 9 | it("should create string from template with params", () => { 10 | expect(createAnnotation(t("foo", { world: "World!" }), dict)).toEqual("Hello World!") 11 | expect(createAnnotation(t("foo", { name: "Foo!", world: "World!" }), dict)).toEqual("Hello Foo!") 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /test/asyncapi/asyncapi.test.ts: -------------------------------------------------------------------------------- 1 | import { compareAsyncApi, nonBreaking } from "../../src" 2 | import { yaml } from "../helpers" 3 | 4 | const metaKey = Symbol("diff") 5 | 6 | describe("Compare simple AsyncApi documents", () => { 7 | it("should compare simple AsyncApi documents", () => { 8 | const before = yaml` 9 | asyncapi: 2.6.0 10 | info: 11 | title: Hello world 12 | version: '0.1.0' 13 | channels: 14 | hello: 15 | publish: 16 | message: 17 | payload: 18 | type: string 19 | ` 20 | 21 | const after = yaml` 22 | asyncapi: 2.6.0 23 | info: 24 | title: Hello world application 25 | version: '0.1.1' 26 | channels: 27 | hello: 28 | publish: 29 | message: 30 | payload: 31 | type: string 32 | pattern: '^hello .+$' 33 | ` 34 | 35 | const { diffs, merged } = compareAsyncApi(before, after, { metaKey }) 36 | 37 | expect(diffs.length).toEqual(3) 38 | // diffs.forEach((diff) => { 39 | // expect(diff).toHaveProperty("description") 40 | // expect(diff.description).not.toEqual("") 41 | // expect(diff.type).not.toEqual("unclassified") 42 | // }) 43 | }) 44 | 45 | it("should compare combinary message in AsyncApi documents", () => { 46 | const before = yaml` 47 | asyncapi: 2.6.0 48 | channels: 49 | hello: 50 | publish: 51 | message: 52 | oneOf: 53 | - payload: 54 | type: string 55 | - payload: 56 | type: number 57 | ` 58 | 59 | const after = yaml` 60 | asyncapi: 2.6.0 61 | channels: 62 | hello: 63 | publish: 64 | message: 65 | oneOf: 66 | - payload: 67 | type: string 68 | - payload: 69 | type: object 70 | - payload: 71 | type: number 72 | ` 73 | 74 | const { diffs, merged } = compareAsyncApi(before, after, { metaKey }) 75 | 76 | expect(diffs.length).toEqual(1) 77 | expect(diffs[0]).toMatchObject({ action: "add", type: nonBreaking }) 78 | // diffs.forEach((diff) => { 79 | // expect(diff).toHaveProperty("description") 80 | // expect(diff.description).not.toEqual("") 81 | // expect(diff.type).not.toEqual("unclassified") 82 | // }) 83 | }) 84 | 85 | it("should be nullable query for Scalar result", () => { 86 | const before = yaml` 87 | asyncapi: 2.5.0 88 | info: 89 | title: Example 90 | version: 0.1.0 91 | channels: 92 | user/signedup: 93 | subscribe: 94 | message: 95 | description: An event describing that a user signed up. 96 | payload: 97 | type: object 98 | properties: 99 | fullName: 100 | type: string 101 | email: 102 | type: string 103 | format: email 104 | 105 | ` 106 | 107 | const after = yaml` 108 | asyncapi: 2.5.0 109 | info: 110 | title: Example 111 | version: 0.1.0 112 | channels: 113 | user/signedup: 114 | subscribe: 115 | message: 116 | description: An event describing that a user just signed up. 117 | payload: 118 | type: object 119 | additionalProperties: false 120 | properties: 121 | fullName: 122 | type: string 123 | email: 124 | type: string 125 | format: email 126 | age: 127 | type: integer 128 | minimum: 18 129 | ` 130 | 131 | const { diffs, merged } = compareAsyncApi(before, after, { metaKey }) 132 | 133 | expect(diffs.length).toEqual(3) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /test/graphapi/arguments.test.ts: -------------------------------------------------------------------------------- 1 | import { compareGraphApi } from "../../src" 2 | import { graphapi } from "../helpers" 3 | 4 | const metaKey = Symbol("diff") 5 | 6 | describe("GraphQL arguments", () => { 7 | it("should compare schemas with changes in operation arguments", () => { 8 | const before = graphapi` 9 | type Query { 10 | todo( 11 | id: ID! 12 | isCompleted: Boolean 13 | ): String 14 | } 15 | ` 16 | 17 | const after = graphapi` 18 | type Query { 19 | todo( 20 | id: ID! 21 | 22 | "A default value of false" 23 | isCompleted: Boolean! = false 24 | 25 | newArgument: String 26 | ): String 27 | } 28 | ` 29 | 30 | const { diffs, merged } = compareGraphApi(before, after, { metaKey }) 31 | 32 | expect(diffs.length).toEqual(4) 33 | diffs.forEach((diff) => { 34 | expect(diff).toHaveProperty("description") 35 | expect(diff.description).not.toEqual("") 36 | expect(diff.type).not.toEqual("unclassified") 37 | }) 38 | }) 39 | 40 | it("should compare schemas with changes in type arguments", () => { 41 | const before = graphapi` 42 | type Query { 43 | company(id: ID!): Company 44 | } 45 | 46 | type Company { 47 | id: ID! 48 | name: String 49 | offices(limit: Int!, after: ID): Office 50 | } 51 | 52 | type Office { 53 | id: ID! 54 | name: String 55 | } 56 | ` 57 | 58 | const after = graphapi` 59 | type Query { 60 | company(id: ID!): Company 61 | } 62 | 63 | type Company { 64 | id: ID! 65 | name: String 66 | offices(limit: Int, after: ID): Office 67 | } 68 | 69 | type Office { 70 | id: ID! 71 | name: String 72 | } 73 | ` 74 | 75 | const { diffs, merged } = compareGraphApi(before, after, { metaKey }) 76 | 77 | expect(diffs.length).toEqual(2) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /test/graphapi/directives.test.ts: -------------------------------------------------------------------------------- 1 | import { compareGraphApi } from "../../src" 2 | import { graphapi } from "../helpers" 3 | 4 | const metaKey = Symbol("diff") 5 | 6 | describe("GraphQL directives", () => { 7 | it("should compare schemas with changes in arguments", () => { 8 | const before = graphapi` 9 | directive @limit(offset: Int = 0, limit: Int = 20) on FIELD | FIELD_DEFINITION 10 | 11 | input Filter { 12 | id: [ID!] 13 | 14 | "A default value of false" 15 | isCompleted: Boolean = false 16 | } 17 | 18 | type Query { 19 | todos( 20 | filters: [Filter!] 21 | ): [String!] @limit 22 | } 23 | ` 24 | 25 | const after = graphapi` 26 | directive @limit(offset: Int = 0, limit: Int = 30) on FIELD | FIELD_DEFINITION 27 | directive @example(value: String) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION 28 | 29 | input Filter { 30 | id: [ID!] @example(value: ["1", "2"]) 31 | 32 | "A default value of false" 33 | isCompleted: Boolean = false 34 | } 35 | 36 | type Query { 37 | todos( 38 | filters: [Filter!] @deprecated(reason: "not used") 39 | ): [String!] @limit 40 | } 41 | ` 42 | 43 | const { diffs, merged } = compareGraphApi(before, after, { metaKey }) 44 | 45 | expect(diffs.length).toEqual(5) 46 | diffs.forEach((diff) => { 47 | if (diff.type !== "unclassified") { 48 | expect(diff).toHaveProperty("description") 49 | expect(diff.description).not.toEqual("") 50 | } 51 | }) 52 | }) 53 | 54 | it("should ", () => { 55 | const before = graphapi` 56 | directive @example(value: String) on FIELD_DEFINITION 57 | 58 | type Query { 59 | todo: Object! 60 | } 61 | 62 | type Object { 63 | """Id of the object""" 64 | id: ID 65 | name: String @example(value: "dog") 66 | } 67 | ` 68 | 69 | const after = graphapi` 70 | directive @example(value: String) on FIELD | FIELD_DEFINITION 71 | 72 | type Query { 73 | todo: Object! 74 | } 75 | 76 | type Object { 77 | """Id of the object""" 78 | id: ID 79 | name: String @example(value: "cat") 80 | } 81 | ` 82 | 83 | const { diffs, merged } = compareGraphApi(before, after, { metaKey }) 84 | 85 | expect(diffs.length).toEqual(3) 86 | }) 87 | 88 | it("should ", () => { 89 | const before = graphapi` 90 | directive @example(value: String) on FIELD_DEFINITION 91 | directive @test(value: String) on FIELD | FIELD_DEFINITION 92 | 93 | type Query { 94 | todo: Object! 95 | } 96 | 97 | type Object { 98 | """Id of the object""" 99 | id: ID 100 | name: String @example(value: "dog") @test(value: "foo") 101 | } 102 | ` 103 | 104 | const after = graphapi` 105 | directive @example(value: String) on FIELD | FIELD_DEFINITION 106 | directive @test2(value: String) on QUERY | FIELD_DEFINITION 107 | 108 | type Query { 109 | todo: Object! 110 | } 111 | 112 | type Object { 113 | """Id of the object""" 114 | id: ID 115 | name: String @example(value: "cat") @test2(value: "foo") 116 | } 117 | ` 118 | 119 | const { diffs, merged } = compareGraphApi(before, after, { metaKey }) 120 | 121 | expect(diffs.length).toEqual(9) 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /test/graphapi/graphschema.test.ts: -------------------------------------------------------------------------------- 1 | import { compareGraphApi } from "../../src" 2 | import { graphapi } from "../helpers" 3 | 4 | const metaKey = Symbol("diff") 5 | 6 | describe("GraphQL schema", () => { 7 | it("should compare schemas with changes in enum", () => { 8 | const before = graphapi` 9 | type Query { 10 | episode(id: ID!): Episode 11 | } 12 | 13 | enum Episode { 14 | """episode 1""" 15 | NEWHOPE 16 | """episode 2""" 17 | EMPIRE @deprecated (reason: "was deleted") 18 | JEDI 19 | NEWEPISOE 20 | } 21 | ` 22 | 23 | const after = graphapi` 24 | type Query { 25 | episode(id: ID!): Episode 26 | } 27 | 28 | enum Episode { 29 | """episode #1""" 30 | NEWHOPE 31 | """episode 2""" 32 | EMPIRE @deprecated (reason: "was deleted with really long reason which can explain why it was deleted") 33 | JEDI 34 | } 35 | ` 36 | 37 | const { diffs, merged } = compareGraphApi(before, after, { metaKey }) 38 | 39 | expect(diffs.length).toEqual(6) 40 | diffs.forEach((diff, i) => { 41 | if (i > 2) { 42 | return 43 | } 44 | expect(diff).toHaveProperty("description") 45 | expect(diff.description).not.toEqual("") 46 | expect(diff.type).not.toEqual("unclassified") 47 | }) 48 | }) 49 | 50 | it("should compare schemas with changes in union", () => { 51 | const before = graphapi` 52 | type Query { 53 | "A Query with 1 required argument and 1 optional argument" 54 | todo( 55 | id: ID! 56 | 57 | "A default value of false" 58 | isCompleted: Boolean = false 59 | ): Response 60 | } 61 | 62 | union Response = StringResponse | NumberResponse 63 | 64 | type StringResponse { 65 | title: String 66 | } 67 | 68 | type NumberResponse { 69 | index: Int 70 | } 71 | ` 72 | 73 | const after = graphapi` 74 | type Query { 75 | "A Query with 1 required argument and 1 optional argument" 76 | todo( 77 | id: ID! 78 | 79 | "A default value of false" 80 | isCompleted: Boolean = false 81 | ): Response 82 | } 83 | 84 | union Response = StringResponse | NumberResponse | BooleanResponse 85 | 86 | type StringResponse { 87 | title: String 88 | } 89 | 90 | type NumberResponse { 91 | index: Int 92 | } 93 | 94 | type BooleanResponse { 95 | flag: Boolean 96 | } 97 | ` 98 | 99 | const { diffs, merged } = compareGraphApi(before, after, { metaKey }) 100 | 101 | expect(diffs.length).toEqual(3) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /test/graphapi/operation.test.ts: -------------------------------------------------------------------------------- 1 | import { compareGraphApi } from "../../src" 2 | import { graphapi } from "../helpers" 3 | 4 | const metaKey = Symbol("diff") 5 | 6 | describe("GraphQL operations", () => { 7 | it("should compare schemas with changes in arguments", () => { 8 | const before = graphapi` 9 | type Query { 10 | "A Query with 1 required argument and 1 optional argument" 11 | todo( 12 | id: ID! 13 | isCompleted: Boolean 14 | ): String 15 | } 16 | ` 17 | 18 | const after = graphapi` 19 | type Query { 20 | "A Query with 2 required argument and 0 optional argument" 21 | todo( 22 | id: ID! 23 | isCompleted: Boolean! 24 | ): String! 25 | } 26 | ` 27 | 28 | const { diffs, merged } = compareGraphApi(before, after, { metaKey }) 29 | 30 | expect(diffs.length).toEqual(3) 31 | diffs.forEach((diff) => { 32 | expect(diff).toHaveProperty("description") 33 | expect(diff.description).not.toEqual("") 34 | expect(diff.type).not.toEqual("unclassified") 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /test/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { applyOperation, type Operation } from "fast-json-patch" 2 | import { buildFromSchema, type GraphApiSchema } from "gqlapi" 3 | import { buildSchema } from "graphql" 4 | import type { JsonPath } from "json-crawl" 5 | import YAML from "js-yaml" 6 | import path from "node:path" 7 | import fs from "node:fs" 8 | 9 | import { apiDiff, apiMerge, getKeyValue, buildPath, type ComapreOptions, type CompareRules } from "../../src" 10 | 11 | export const yaml = (strings: TemplateStringsArray): object => { 12 | return YAML.load(strings[0]) as object 13 | } 14 | 15 | export const graphapi = (strings: TemplateStringsArray): GraphApiSchema => { 16 | return buildFromSchema(buildSchema(strings[0], { noLocation: true })) 17 | } 18 | 19 | export class ExampleResource { 20 | private res: any = {} 21 | public externalSources: any = {} 22 | 23 | constructor( 24 | private filename: string, 25 | public rules?: CompareRules, 26 | ) { 27 | const resPath = path.join(__dirname, "../resources/", this.filename) 28 | const data = fs.readFileSync(resPath, "utf8") 29 | if (/.(yaml|YAML|yml|YML)$/g.test(filename)) { 30 | try { 31 | this.res = YAML.load(data) 32 | } catch (e) { 33 | console.log(e) 34 | } 35 | } else if (/.(graphql|gql)$/g.test(filename)) { 36 | try { 37 | const schema = buildSchema(data, { noLocation: true }) 38 | this.res = buildFromSchema(schema) 39 | } catch (e) { 40 | console.log(e) 41 | } 42 | } 43 | } 44 | 45 | public clone(patches: Operation[] = []) { 46 | const res = JSON.parse(JSON.stringify(this.res)) 47 | for (const patch of patches) { 48 | applyOperation(res, patch) 49 | } 50 | return res 51 | } 52 | 53 | public diff(after: any) { 54 | return apiDiff(this.res, after, { rules: this.rules, externalSources: this.externalSources }) 55 | } 56 | 57 | public merge(after: any, options?: ComapreOptions) { 58 | return apiMerge(this.res, after, { ...options, rules: this.rules, externalSources: this.externalSources }) 59 | } 60 | 61 | public getValue(path: JsonPath) { 62 | // path = typeof path === "string" ? parsePath(path) : path 63 | return getKeyValue(this.res, ...path) 64 | } 65 | 66 | // public findExternalSources () { 67 | // return findExternalRefs(this.res) 68 | // } 69 | } 70 | 71 | export const addPatch = (pathArr: any[], value: any): Operation => { 72 | return { 73 | op: "add", 74 | path: buildPath(pathArr), 75 | value, 76 | } 77 | } 78 | 79 | export const replacePatch = (pathArr: any[], value: any): Operation => { 80 | return { 81 | op: "replace", 82 | path: buildPath(pathArr), 83 | value, 84 | } 85 | } 86 | 87 | export const removePatch = (pathArr: any[]): Operation => { 88 | return { 89 | op: "remove", 90 | path: buildPath(pathArr), 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/jsonSchema/array-schema.test.ts: -------------------------------------------------------------------------------- 1 | import { breaking, compareJsonSchema, nonBreaking } from "../../src" 2 | 3 | const metaKey = Symbol("diff") 4 | 5 | describe("Comapre array jsonSchema", () => { 6 | it("should compare array jsonSchema with validations change", () => { 7 | const before = { 8 | items: { 9 | type: "string", 10 | }, 11 | minItems: 1, 12 | } 13 | 14 | const after = { 15 | type: "array", 16 | uniqueItems: true, 17 | minItems: 0, 18 | maxItems: 3, 19 | } 20 | 21 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 22 | 23 | expect(diffs.length).toEqual(4) 24 | diffs.forEach((diff) => { 25 | expect(diff).toHaveProperty("description") 26 | expect(diff.description).not.toEqual("") 27 | expect(diff.type).not.toEqual("unclassified") 28 | }) 29 | 30 | expect(merged).toMatchObject({ 31 | items: { 32 | type: "string", 33 | }, 34 | uniqueItems: true, 35 | minItems: 0, 36 | maxItems: 3, 37 | }) 38 | expect(merged[metaKey]).toMatchObject({ 39 | items: { action: "remove", type: nonBreaking }, 40 | minItems: { action: "replace", replaced: 1, type: nonBreaking }, 41 | maxItems: { action: "add", type: breaking }, 42 | uniqueItems: { action: "add", type: breaking }, 43 | }) 44 | }) 45 | 46 | it("should compare array jsonSchema with validations change", () => { 47 | const before = { 48 | type: "array", 49 | items: { 50 | type: "string", 51 | }, 52 | } 53 | 54 | const after = { 55 | type: "array", 56 | items: [{ type: "number" }], 57 | additionalItems: { 58 | type: "string", 59 | }, 60 | } 61 | 62 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 63 | 64 | expect(diffs.length).toEqual(1) 65 | diffs.forEach((diff) => { 66 | expect(diff).toHaveProperty("description") 67 | expect(diff.description).not.toEqual("") 68 | expect(diff.type).not.toEqual("unclassified") 69 | }) 70 | 71 | expect(merged).toMatchObject(after) 72 | expect(merged.items[0][metaKey]).toMatchObject({ 73 | type: { action: "replace", replaced: "string", type: breaking }, 74 | }) 75 | }) 76 | 77 | it("should compare array jsonSchema (type change to array)", () => { 78 | const before = { 79 | type: "number", 80 | } 81 | 82 | const after = { 83 | type: "array", 84 | items: { 85 | type: "number", 86 | }, 87 | } 88 | 89 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 90 | 91 | expect(diffs.length).toEqual(2) 92 | diffs.forEach((diff) => { 93 | expect(diff).toHaveProperty("description") 94 | expect(diff.description).not.toEqual("") 95 | expect(diff.type).not.toEqual("unclassified") 96 | }) 97 | 98 | expect(merged).toMatchObject(after) 99 | expect(merged[metaKey]).toMatchObject({ 100 | type: { action: "replace", replaced: "number", type: breaking }, 101 | items: { action: "add", type: nonBreaking }, 102 | }) 103 | }) 104 | 105 | it("should merge jsonSchema with array items", () => { 106 | const before = { 107 | items: [{ type: "string" }, { type: "boolean" }], 108 | } 109 | const after = { 110 | items: [{ type: "boolean" }], 111 | } 112 | 113 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 114 | 115 | expect(diffs.length).toEqual(2) 116 | diffs.forEach((diff) => { 117 | expect(diff).toHaveProperty("description") 118 | expect(diff.description).not.toEqual("") 119 | expect(diff.type).not.toEqual("unclassified") 120 | }) 121 | 122 | expect(merged[metaKey]).toMatchObject({ 123 | items: { array: { 1: { action: "remove", type: breaking } } }, 124 | }) 125 | expect(merged.items[0][metaKey]).toMatchObject({ 126 | type: { action: "replace", replaced: "string", type: breaking }, 127 | }) 128 | }) 129 | 130 | it("should merge jsonSchema with array items", () => { 131 | const before = { 132 | type: "array", 133 | items: [{ type: "string" }, { type: "boolean" }], 134 | } 135 | const after = { 136 | type: "array", 137 | } 138 | 139 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 140 | 141 | expect(diffs.length).toEqual(2) 142 | diffs.forEach((diff) => { 143 | expect(diff).toHaveProperty("description") 144 | expect(diff.description).not.toEqual("") 145 | expect(diff.type).not.toEqual("unclassified") 146 | }) 147 | 148 | expect(merged[metaKey]).toMatchObject({ 149 | items: { 150 | array: { 151 | 0: { action: "remove", type: breaking }, 152 | 1: { action: "remove", type: breaking }, 153 | }, 154 | }, 155 | }) 156 | }) 157 | 158 | it("should merge jsonSchema with array items change", () => { 159 | const before: any = { 160 | type: "array", 161 | items: [{ type: "string" }, { type: "boolean" }], 162 | } 163 | const after: any = { 164 | items: { 165 | type: "string", 166 | }, 167 | } 168 | 169 | const expectedMerged = { 170 | type: "array", 171 | items: [{ type: "string" }, { type: "string" }], 172 | additionalItems: { 173 | type: "string", 174 | }, 175 | } 176 | 177 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 178 | 179 | expect(diffs.length).toEqual(2) 180 | diffs.forEach((diff) => { 181 | expect(diff).toHaveProperty("description") 182 | expect(diff.description).not.toEqual("") 183 | expect(diff.type).not.toEqual("unclassified") 184 | }) 185 | 186 | expect(merged).toMatchObject(expectedMerged) 187 | expect(merged.items[1][metaKey]).toMatchObject({ 188 | type: { action: "replace", replaced: "boolean", type: breaking }, 189 | }) 190 | expect(merged[metaKey]).toMatchObject({ 191 | additionalItems: { action: "add", type: nonBreaking }, 192 | }) 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /test/jsonSchema/object-schema.test.ts: -------------------------------------------------------------------------------- 1 | import { breaking, compareJsonSchema, nonBreaking } from "../../src" 2 | 3 | const metaKey = Symbol("diff") 4 | 5 | describe("Compare object jsonSchema", () => { 6 | it("should compare object jsonSchema with required remove", () => { 7 | const before = { 8 | type: "object", 9 | required: ["id"], 10 | properties: { 11 | name: { type: "string" }, 12 | }, 13 | } 14 | 15 | const after = { 16 | // type: "object" - should be calculated 17 | properties: { 18 | id: { type: "number" }, 19 | }, 20 | } 21 | 22 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 23 | 24 | expect(diffs.length).toEqual(3) 25 | 26 | diffs.forEach((diff) => expect(diff).toHaveProperty("description")) 27 | diffs.forEach((diff) => expect(diff.type).not.toEqual("unclassified")) 28 | 29 | expect(merged).toMatchObject({ 30 | type: "object", 31 | required: ["id"], 32 | properties: { 33 | id: { type: "number" }, 34 | name: { type: "string" }, 35 | }, 36 | }) 37 | expect(merged[metaKey]).toMatchObject({ 38 | required: { array: { 0: { action: "remove", type: nonBreaking } } }, 39 | }) 40 | expect(merged.properties[metaKey]).toMatchObject({ 41 | id: { action: "add", type: nonBreaking }, 42 | name: { action: "remove", type: nonBreaking }, 43 | }) 44 | }) 45 | 46 | it("should compare object jsonSchema with required change", () => { 47 | const before = { 48 | required: ["id", "foo"], 49 | } 50 | 51 | const after = { 52 | required: ["name", "id"], 53 | properties: { 54 | name: { 55 | type: "string", 56 | default: "", 57 | }, 58 | }, 59 | } 60 | 61 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 62 | 63 | expect(diffs.length).toEqual(3) 64 | diffs.forEach((diff) => { 65 | expect(diff).toHaveProperty("description") 66 | expect(diff.description).not.toEqual("") 67 | expect(diff.type).not.toEqual("unclassified") 68 | }) 69 | 70 | expect(merged).toMatchObject({ 71 | required: ["id", "foo", "name"], 72 | properties: { 73 | name: { 74 | type: "string", 75 | default: "", 76 | }, 77 | }, 78 | }) 79 | expect(merged[metaKey]).toMatchObject({ 80 | required: { 81 | array: { 82 | 1: { action: "remove", type: nonBreaking }, 83 | 2: { action: "add", type: nonBreaking }, 84 | }, 85 | }, 86 | }) 87 | expect(merged.properties[metaKey]).toMatchObject({ 88 | name: { action: "add", type: nonBreaking }, 89 | }) 90 | }) 91 | 92 | it("should compare object jsonSchema with required add", () => { 93 | const before = { 94 | properties: { 95 | id: { type: "string" }, 96 | }, 97 | } 98 | 99 | const after = { 100 | required: ["id", "name", 1], 101 | properties: { 102 | id: { type: "string" }, 103 | name: { type: "string" }, 104 | }, 105 | } 106 | 107 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 108 | 109 | expect(diffs.length).toEqual(3) 110 | diffs.forEach((diff) => { 111 | expect(diff).toHaveProperty("description") 112 | expect(diff.description).not.toEqual("") 113 | expect(diff.type).not.toEqual("unclassified") 114 | }) 115 | 116 | expect(merged).toMatchObject({ 117 | required: ["id", "name"], 118 | properties: { 119 | id: { type: "string" }, 120 | name: { type: "string" }, 121 | }, 122 | }) 123 | expect(merged[metaKey]).toMatchObject({ 124 | required: { 125 | array: { 126 | 0: { action: "add", type: breaking }, 127 | 1: { action: "add", type: breaking }, 128 | }, 129 | }, 130 | }) 131 | expect(merged.properties[metaKey]).toMatchObject({ 132 | name: { action: "add", type: breaking }, 133 | }) 134 | }) 135 | 136 | it("should compare object jsonSchema with additionalProperties", () => { 137 | const before = { 138 | required: ["id"], 139 | properties: { 140 | id: { type: "string" }, 141 | name: { type: "string" }, 142 | }, 143 | } 144 | 145 | const after = { 146 | required: ["id"], 147 | additionalProperties: { type: "string" }, 148 | minProperties: 1, 149 | maxProperties: 3, 150 | propertyNames: { 151 | enum: ["id", "name", "foo"], 152 | }, 153 | } 154 | 155 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 156 | 157 | expect(diffs.length).toEqual(6) 158 | diffs.forEach((diff) => { 159 | expect(diff).toHaveProperty("description") 160 | expect(diff.description).not.toEqual("") 161 | expect(diff.type).not.toEqual("unclassified") 162 | }) 163 | 164 | expect(merged).toMatchObject({ 165 | properties: { 166 | id: { type: "string" }, 167 | name: { type: "string" }, 168 | }, 169 | additionalProperties: { type: "string" }, 170 | minProperties: 1, 171 | maxProperties: 3, 172 | propertyNames: { 173 | enum: ["id", "name", "foo"], 174 | }, 175 | }) 176 | expect(merged[metaKey]).toMatchObject({ 177 | additionalProperties: { action: "add", type: nonBreaking }, 178 | minProperties: { action: "add", type: breaking }, 179 | maxProperties: { action: "add", type: breaking }, 180 | propertyNames: { action: "add", type: breaking }, 181 | }) 182 | expect(merged.properties[metaKey]).toMatchObject({ 183 | name: { action: "remove", type: nonBreaking }, 184 | id: { action: "remove", type: nonBreaking }, 185 | }) 186 | }) 187 | 188 | it("should compare object jsonSchema with additionalProperties change", () => { 189 | const before = { 190 | additionalProperties: true, 191 | } 192 | 193 | const after = { 194 | additionalProperties: { 195 | type: "number", 196 | }, 197 | } 198 | 199 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 200 | 201 | expect(diffs.length).toEqual(1) 202 | diffs.forEach((diff) => { 203 | expect(diff).toHaveProperty("description") 204 | expect(diff.description).not.toEqual("") 205 | expect(diff.type).not.toEqual("unclassified") 206 | }) 207 | 208 | expect(merged).toMatchObject(after) 209 | expect(merged[metaKey]).toMatchObject({ 210 | additionalProperties: { action: "replace", replaced: true, type: nonBreaking }, 211 | }) 212 | }) 213 | 214 | it("should compare object jsonSchema with additionalProperties validation change", () => { 215 | const before = { 216 | additionalProperties: { 217 | type: "number", 218 | }, 219 | } 220 | 221 | const after = { 222 | additionalProperties: { 223 | type: "string", 224 | maxLength: 3, 225 | }, 226 | } 227 | 228 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 229 | 230 | expect(diffs.length).toEqual(2) 231 | diffs.forEach((diff) => { 232 | expect(diff).toHaveProperty("description") 233 | expect(diff.description).not.toEqual("") 234 | expect(diff.type).not.toEqual("unclassified") 235 | }) 236 | 237 | expect(merged).toMatchObject(after) 238 | expect(merged.additionalProperties[metaKey]).toMatchObject({ 239 | maxLength: { action: "add", type: breaking }, 240 | type: { action: "replace", replaced: "number", type: breaking }, 241 | }) 242 | }) 243 | 244 | it("should compare object jsonSchema with patternProperties", () => { 245 | const before = { 246 | patternProperties: { 247 | "^[a-z0-9]+$": { 248 | type: "string", 249 | }, 250 | }, 251 | } 252 | const after = { 253 | type: "object", 254 | patternProperties: { 255 | "^[a-z0-9]+$": { 256 | type: "number", 257 | }, 258 | "^[0-9]+$": { 259 | type: "string", 260 | }, 261 | }, 262 | } 263 | 264 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 265 | 266 | expect(diffs.length).toEqual(2) 267 | diffs.forEach((diff) => { 268 | expect(diff).toHaveProperty("description") 269 | expect(diff.description).not.toEqual("") 270 | expect(diff.type).not.toEqual("unclassified") 271 | }) 272 | 273 | expect(merged).toMatchObject(after) 274 | expect(merged.patternProperties[metaKey]).toMatchObject({ 275 | "^[0-9]+$": { action: "add", type: breaking }, 276 | }) 277 | expect(merged.patternProperties["^[a-z0-9]+$"][metaKey]).toMatchObject({ 278 | type: { action: "replace", replaced: "string", type: breaking }, 279 | }) 280 | }) 281 | }) 282 | -------------------------------------------------------------------------------- /test/jsonSchema/schema-with-refs.test.ts: -------------------------------------------------------------------------------- 1 | import { compareJsonSchema } from "../../src" 2 | import { yaml } from "../helpers" 3 | 4 | const metaKey = Symbol("diff") 5 | 6 | describe("schema with references", () => { 7 | it("should merge jsonSchema with refs", () => { 8 | const before = yaml` 9 | type: object 10 | properties: 11 | id: 12 | $ref: '#/definitions/id' 13 | definitions: 14 | id: 15 | title: id 16 | type: string 17 | ` 18 | 19 | const after = yaml` 20 | type: object 21 | properties: 22 | id: 23 | $ref: '#/definitions/id' 24 | name: 25 | $ref: '#/definitions/name' 26 | definitions: 27 | id: 28 | title: id 29 | type: number 30 | name: 31 | title: name 32 | type: string 33 | ` 34 | 35 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 36 | 37 | expect(diffs.length).toEqual(4) 38 | expect(merged).toMatchObject({ 39 | ...after, 40 | properties: { 41 | id: { 42 | title: "id", 43 | type: "number", 44 | }, 45 | name: { $ref: "#/definitions/name" }, 46 | }, 47 | }) 48 | expect(merged.properties[metaKey]).toMatchObject({ 49 | name: { action: "add" }, 50 | }) 51 | expect(merged.definitions[metaKey]).toMatchObject({ 52 | name: { action: "add" }, 53 | }) 54 | expect(merged.definitions.id[metaKey]).toMatchObject({ 55 | type: { action: "replace", replaced: "string" }, 56 | }) 57 | expect(merged.properties.id[metaKey]).toMatchObject({ 58 | type: { action: "replace", replaced: "string" }, 59 | }) 60 | }) 61 | 62 | it("should merge jsonSchema with refs change", () => { 63 | const before = yaml` 64 | type: object 65 | properties: 66 | id: 67 | $ref: '#/definitions/id' 68 | name: 69 | type: string 70 | definitions: 71 | id: 72 | title: id 73 | type: string 74 | ` 75 | 76 | const after = yaml` 77 | type: object 78 | properties: 79 | id: 80 | $ref: '#/definitions/id' 81 | name: 82 | $ref: '#/definitions/name' 83 | definitions: 84 | id: 85 | title: id 86 | type: number 87 | name: 88 | title: name 89 | type: string 90 | ` 91 | 92 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 93 | 94 | expect(diffs.length).toEqual(4) 95 | expect(merged).toMatchObject({ 96 | ...after, 97 | properties: { 98 | id: { 99 | title: "id", 100 | type: "number", 101 | }, 102 | name: { 103 | title: "name", 104 | type: "string", 105 | }, 106 | }, 107 | }) 108 | expect(merged.properties.name[metaKey]).toMatchObject({ 109 | title: { action: "add" }, 110 | }) 111 | expect(merged.definitions[metaKey]).toMatchObject({ 112 | name: { action: "add" }, 113 | }) 114 | expect(merged.definitions.id[metaKey]).toMatchObject({ 115 | type: { action: "replace", replaced: "string" }, 116 | }) 117 | expect(merged.properties.id[metaKey]).toMatchObject({ 118 | type: { action: "replace", replaced: "string" }, 119 | }) 120 | }) 121 | 122 | it("should merge jsonSchema with cycle refs", () => { 123 | const before = yaml` 124 | type: object 125 | properties: 126 | id: 127 | title: id 128 | type: string 129 | parent: 130 | $ref: '#' 131 | ` 132 | 133 | const after = yaml` 134 | type: object 135 | required: 136 | - id 137 | properties: 138 | id: 139 | title: id 140 | type: string 141 | parent: 142 | $ref: '#' 143 | ` 144 | 145 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 146 | 147 | expect(diffs.length).toEqual(1) 148 | expect(merged[metaKey]).toMatchObject({ 149 | required: { array: { 0: { action: "add" } } }, 150 | }) 151 | }) 152 | 153 | it("should merge jsonSchema with changes in cycle refs", () => { 154 | const before = yaml` 155 | type: object 156 | properties: 157 | model: 158 | $ref: '#/definitions/model' 159 | definitions: 160 | id: 161 | title: id 162 | type: string 163 | model: 164 | type: object 165 | properties: 166 | id: 167 | $ref: '#/definitions/id' 168 | parent: 169 | $ref: '#/definitions/model' 170 | ` 171 | 172 | const after = yaml` 173 | type: object 174 | properties: 175 | model: 176 | $ref: '#/definitions/model' 177 | definitions: 178 | id: 179 | title: id 180 | type: string 181 | model: 182 | type: object 183 | properties: 184 | id: 185 | $ref: '#/definitions/id' 186 | ` 187 | 188 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 189 | 190 | expect(diffs.length).toEqual(2) 191 | expect(merged.definitions.model.properties[metaKey]).toMatchObject({ 192 | parent: { action: "remove" }, 193 | }) 194 | expect(merged.properties.model.properties[metaKey]).toMatchObject({ 195 | parent: { action: "remove" }, 196 | }) 197 | }) 198 | }) 199 | 200 | describe("schema with broken reference", () => { 201 | it("should merge jsonSchema with broken refs", () => { 202 | const before = yaml` 203 | type: object 204 | properties: 205 | id: 206 | type: string 207 | ` 208 | 209 | const after = yaml` 210 | type: object 211 | properties: 212 | id: 213 | $ref: '#/definitions/id' 214 | ` 215 | 216 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 217 | 218 | expect(diffs.length).toEqual(2) 219 | expect(merged.properties.id[metaKey]).toMatchObject({ 220 | type: { action: "remove" }, 221 | $ref: { action: "add" }, 222 | }) 223 | }) 224 | }) 225 | -------------------------------------------------------------------------------- /test/jsonSchema/simple-schema.test.ts: -------------------------------------------------------------------------------- 1 | import { annotation, breaking, compareJsonSchema, deprecated, nonBreaking, unclassified } from "../../src" 2 | 3 | const metaKey = Symbol("diff") 4 | 5 | describe("Compare simple jsonSchema", () => { 6 | it("should compare boolean jsonSchema", () => { 7 | const before = { 8 | title: "Boolean", 9 | type: "boolean", 10 | description: "Boolean schema", 11 | default: false, 12 | maximum: 10, // should be unclassified 13 | } 14 | 15 | const after = { 16 | title: "boolean", 17 | type: "boolean", 18 | description: "Updated Boolean schema", 19 | const: true, 20 | } 21 | 22 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 23 | 24 | expect(diffs.length).toEqual(5) 25 | 26 | diffs.forEach((diff, i) => { 27 | expect(diff).toHaveProperty("description") 28 | expect(diff.description).not.toEqual("") 29 | i !== 1 && expect(diff.type).not.toEqual("unclassified") 30 | }) 31 | 32 | expect(merged).toMatchObject({ 33 | title: "boolean", 34 | type: "boolean", 35 | description: "Updated Boolean schema", 36 | default: false, 37 | const: true, 38 | maximum: 10, 39 | }) 40 | 41 | expect(merged[metaKey]).toMatchObject({ 42 | const: { action: "add", type: breaking }, 43 | title: { action: "replace", replaced: "Boolean", type: annotation }, 44 | description: { action: "replace", replaced: "Boolean schema", type: annotation }, 45 | default: { action: "remove", type: breaking }, 46 | maximum: { action: "remove", type: unclassified }, 47 | }) 48 | }) 49 | 50 | it("should compare number jsonSchema", () => { 51 | const before = { 52 | title: "Title", 53 | type: "number", 54 | format: "int64", 55 | maximum: 10, 56 | exclusiveMaximum: true, // should be converted to number 57 | minLength: 3, // should be unclassified 58 | } 59 | 60 | const after = { 61 | type: "number", 62 | "x-prop": "Added custom tag", 63 | default: 0, 64 | exclusiveMaximum: 10, 65 | pattern: "w+", // should be unclassified 66 | } 67 | 68 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 69 | 70 | expect(diffs.length).toEqual(6) 71 | 72 | diffs.forEach((diff) => diff.type !== unclassified && expect(diff).toHaveProperty("description")) 73 | 74 | expect(merged).toMatchObject({ 75 | title: "Title", 76 | type: "number", 77 | format: "int64", 78 | exclusiveMaximum: 10, 79 | "x-prop": "Added custom tag", 80 | default: 0, 81 | minLength: 3, 82 | pattern: "w+", 83 | }) 84 | 85 | expect(merged[metaKey]).toMatchObject({ 86 | default: { action: "add", type: nonBreaking }, 87 | title: { action: "remove", type: annotation }, 88 | "x-prop": { action: "add", type: unclassified }, 89 | format: { action: "remove", type: nonBreaking }, 90 | minLength: { action: "remove", type: unclassified }, 91 | pattern: { action: "add", type: unclassified }, 92 | }) 93 | }) 94 | 95 | it("should compare string jsonSchema", () => { 96 | const before = { 97 | title: "Title", 98 | type: "string", 99 | const: "foo", // should be converted to enum 100 | minLength: 3, 101 | } 102 | 103 | const after = { 104 | // type: "string" - should be calculated 105 | description: "Added string description", 106 | default: "foo", 107 | enum: ["baz", "foo"], 108 | pattern: "w+", 109 | minLength: 1, 110 | } 111 | 112 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 113 | 114 | expect(diffs.length).toEqual(6) 115 | 116 | diffs.forEach((diff, i) => { 117 | expect(diff).toHaveProperty("description") 118 | expect(diff.description).not.toEqual("") 119 | expect(diff.type).not.toEqual("unclassified") 120 | }) 121 | 122 | expect(merged).toMatchObject({ 123 | title: "Title", 124 | type: "string", 125 | description: "Added string description", 126 | default: "foo", 127 | enum: ["foo", "baz"], 128 | pattern: "w+", 129 | minLength: 1, 130 | }) 131 | 132 | expect(merged[metaKey]).toMatchObject({ 133 | default: { action: "add", type: nonBreaking }, 134 | enum: { array: { 1: { action: "add", type: nonBreaking } } }, 135 | title: { action: "remove", type: annotation }, 136 | description: { action: "add", type: annotation }, 137 | pattern: { action: "add", type: breaking }, 138 | minLength: { action: "replace", replaced: 3, type: nonBreaking }, 139 | }) 140 | }) 141 | 142 | it("should compare string jsonSchema with example", () => { 143 | const before = { 144 | title: "test", 145 | type: "string", 146 | enum: ["a", "b", "c"], 147 | example: "a", // should be converted to examples 148 | } 149 | 150 | const after = { 151 | title: "test1", 152 | type: "string", 153 | enum: ["a", "d", "c"], 154 | examples: ["a", "c"], 155 | } 156 | 157 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 158 | 159 | expect(diffs.length).toEqual(3) 160 | 161 | diffs.forEach((diff) => { 162 | expect(diff).toHaveProperty("description") 163 | expect(diff.description).not.toEqual("") 164 | expect(diff.type).not.toEqual("unclassified") 165 | }) 166 | 167 | expect(merged).toMatchObject({ 168 | title: "test1", 169 | type: "string", 170 | enum: ["a", "d", "c"], 171 | examples: ["a", "c"], 172 | }) 173 | expect(merged[metaKey]).toMatchObject({ 174 | title: { action: "replace", replaced: "test", type: annotation }, 175 | enum: { array: { 1: { action: "replace", replaced: "b", type: breaking } } }, 176 | examples: { array: { 1: { action: "add", type: annotation } } }, 177 | }) 178 | }) 179 | 180 | it("should comapre string jsonSchema with deprecated", () => { 181 | const before = { 182 | title: "test", 183 | type: "string", 184 | description: "test description", 185 | readOnly: true, 186 | } 187 | 188 | const after = { 189 | title: "test", 190 | type: "string", 191 | description: "test description1", 192 | deprecated: true, 193 | } 194 | 195 | const { diffs, merged } = compareJsonSchema(before, after, { metaKey }) 196 | 197 | expect(diffs.length).toEqual(3) 198 | 199 | diffs.forEach((diff) => { 200 | expect(diff).toHaveProperty("description") 201 | expect(diff.description).not.toEqual("") 202 | expect(diff.type).not.toEqual("unclassified") 203 | }) 204 | 205 | expect(merged).toMatchObject({ 206 | title: "test", 207 | type: "string", 208 | description: "test description1", 209 | readOnly: true, 210 | deprecated: true, 211 | }) 212 | expect(merged[metaKey]).toMatchObject({ 213 | description: { action: "replace", replaced: "test description", type: annotation }, 214 | deprecated: { action: "add", type: deprecated }, 215 | readOnly: { action: "remove", type: nonBreaking }, 216 | }) 217 | }) 218 | }) 219 | -------------------------------------------------------------------------------- /test/openapi/openapi3.test.ts: -------------------------------------------------------------------------------- 1 | import { annotation, breaking, DiffAction, nonBreaking, unclassified } from "../../src" 2 | import { addPatch, ExampleResource } from "../helpers" 3 | 4 | const exampleResource = new ExampleResource("petstore.yaml") 5 | 6 | describe("Test openapi 3 diff", () => { 7 | it("add servers should be non-breaking change", () => { 8 | const path = ["servers", 2] 9 | const value = { 10 | url: "http://localhost:3000", 11 | description: "Local server1", 12 | } 13 | 14 | const after = exampleResource.clone([addPatch(path, value)]) 15 | const diff = exampleResource.diff(after) 16 | expect(diff.length).toEqual(1) 17 | expect(diff).toMatchObject([ 18 | { 19 | path, 20 | after: value, 21 | type: annotation, 22 | }, 23 | ]) 24 | }) 25 | 26 | it("should add diff for rename change with non-breaking change", () => { 27 | const after = exampleResource.clone() 28 | after.paths["/pet/{pet}/uploadImage"] = after.paths["/pet/{petId}/uploadImage"] 29 | delete after.paths["/pet/{petId}/uploadImage"] 30 | after.paths["/pet/{pet}/uploadImage"].post.parameters[0].name = "pet" 31 | 32 | const diff = exampleResource.diff(after) 33 | expect(diff.length).toEqual(2) 34 | expect(diff).toMatchObject([ 35 | { 36 | path: ["paths"], 37 | before: "/pet/{petId}/uploadImage", 38 | after: "/pet/{pet}/uploadImage", 39 | type: nonBreaking, 40 | action: DiffAction.rename, 41 | }, 42 | { path: ["paths", "/pet/{petId}/uploadImage", "post", "parameters", 0, "name"], type: nonBreaking }, 43 | ]) 44 | }) 45 | 46 | it("should add rename diff to merged with non-breaking change", () => { 47 | const after = exampleResource.clone() 48 | after.paths["/pet/{pet}/uploadImage"] = after.paths["/pet/{petId}/uploadImage"] 49 | delete after.paths["/pet/{petId}/uploadImage"] 50 | after.paths["/pet/{pet}/uploadImage"].post.parameters[0].name = "pet" 51 | 52 | const merged = exampleResource.merge(after) 53 | expect(merged.paths.$diff).toMatchObject({ 54 | "/pet/{pet}/uploadImage": { 55 | action: DiffAction.rename, 56 | replaced: "/pet/{petId}/uploadImage", 57 | type: nonBreaking, 58 | }, 59 | }) 60 | }) 61 | 62 | it("should classify required change after rename", () => { 63 | const after = exampleResource.clone() 64 | after.paths["/pet/{pet}"] = after.paths["/pet/{petId}"] 65 | delete after.paths["/pet/{petId}"] 66 | after.components.schemas.Pet.required[0] = "id" 67 | after.components.schemas.Pet.properties.id.default = 0 68 | 69 | const merged = exampleResource.merge(after) 70 | expect(merged.paths.$diff).toMatchObject({ 71 | "/pet/{pet}": { action: DiffAction.rename, replaced: "/pet/{petId}", type: nonBreaking }, 72 | }) 73 | expect( 74 | merged.paths["/pet/{pet}"].get.responses[200].content["application/json"].schema.$diff.required.array, 75 | ).toMatchObject({ 76 | 0: { action: DiffAction.remove, type: breaking }, 77 | 2: { action: DiffAction.add, type: breaking }, 78 | }) 79 | expect(merged.paths["/pet"].post.requestBody.content["application/json"].schema.$diff.required.array).toMatchObject( 80 | { 81 | 0: { action: DiffAction.remove, type: nonBreaking }, 82 | 2: { action: DiffAction.add, type: nonBreaking }, 83 | }, 84 | ) 85 | }) 86 | 87 | it("should add rename diff on media type rename", () => { 88 | const after = exampleResource.clone() 89 | after.paths["/pet"].put.requestBody.content["application/*"] = 90 | after.paths["/pet"].put.requestBody.content["application/json"] 91 | delete after.paths["/pet"].put.requestBody.content["application/json"] 92 | 93 | const merged = exampleResource.merge(after) 94 | expect(merged.paths["/pet"].put.requestBody.content.$diff).toMatchObject({ 95 | "application/*": { action: DiffAction.rename, replaced: "application/json", type: nonBreaking }, 96 | }) 97 | }) 98 | 99 | it("should classify as non-breaking remove of operation security", () => { 100 | const after = exampleResource.clone() 101 | after.paths["/user/createWithList"].post.security = [{}] 102 | 103 | const diffs = exampleResource.diff(after) 104 | expect(diffs).toMatchObject([{ type: nonBreaking }]) 105 | }) 106 | 107 | it("should classify as non-breaking set operation security equal to default", () => { 108 | const after = exampleResource.clone() 109 | after.paths["/user/createWithList"].post.security = [{ api_key: [] }] 110 | 111 | const diffs = exampleResource.diff(after) 112 | expect(diffs).toMatchObject([{ type: nonBreaking }]) 113 | }) 114 | 115 | it("should classify as non-breaking set to default operation security if equal to default", () => { 116 | const after = exampleResource.clone() 117 | delete after.paths["/pet/{petId}"].get.security 118 | 119 | const diffs = exampleResource.diff(after) 120 | expect(diffs).toMatchObject([{ type: nonBreaking }]) 121 | }) 122 | 123 | it("should classify as breaking set to default operation security if not equal to default", () => { 124 | const after = exampleResource.clone() 125 | delete after.paths["/pet/findByTags"].get.security 126 | 127 | const diffs = exampleResource.diff(after) 128 | expect(diffs).toMatchObject([{ type: breaking }]) 129 | }) 130 | 131 | it("should classify as breaking set to default operation security if not equal to default", () => { 132 | const after = exampleResource.clone() 133 | after.paths["/user/{username}"].get.security = [{ petstore_auth: [] }] 134 | 135 | const diffs = exampleResource.diff(after) 136 | expect(diffs).toMatchObject([{ type: breaking }]) 137 | }) 138 | 139 | it("should do not find changes with allOf diff", () => { 140 | const after = exampleResource.clone() 141 | const { xml, ...rest } = after.components.schemas.Order 142 | after.components.schemas.Order = { 143 | ...rest, 144 | allOf: [{ xml }], 145 | } 146 | const diffs = exampleResource.diff(after) 147 | expect(diffs.length).toEqual(0) 148 | }) 149 | 150 | it("should classify as non-breaking change of additionalProperties from any type to true in request", () => { 151 | const after = exampleResource.clone() 152 | after.paths["/pet/{petId}"].post.requestBody.content[ 153 | "application/x-www-form-urlencoded" 154 | ].schema.properties.customProperties.additionalProperties = true 155 | 156 | const diffs = exampleResource.diff(after) 157 | expect(diffs).toMatchObject([{ type: nonBreaking }]) 158 | }) 159 | 160 | it("should not classify as change of response code from 5XX to 5xx", () => { 161 | const after = exampleResource.clone() 162 | after.paths["/pet"].put.responses["5xx"] = after.paths["/pet"].put.responses["5XX"] 163 | delete after.paths["/pet"].put.responses["5XX"] 164 | 165 | const diffs = exampleResource.diff(after) 166 | expect(diffs.length).toEqual(0) 167 | }) 168 | 169 | it("should annotate response content type correctly on content schema change", () => { 170 | const after = exampleResource.clone() 171 | after.components.schemas.Pet.description = "some description" 172 | 173 | const diffs = exampleResource.diff(after) 174 | expect(diffs[0].description).not.toEqual(diffs[1].description) 175 | }) 176 | }) 177 | -------------------------------------------------------------------------------- /test/openapi/operation.test.ts: -------------------------------------------------------------------------------- 1 | import { DiffAction, breaking, compareOpenApi, nonBreaking } from "../../src" 2 | import { yaml } from "../helpers" 3 | 4 | const metaKey = Symbol("diff") 5 | 6 | describe("Openapi 3 operation changes", () => { 7 | it("should comapre documents with different operations", () => { 8 | const before = yaml` 9 | paths: 10 | "/pet": 11 | get: {} 12 | post: {} 13 | "/pet/findById": 14 | get: {} 15 | ` 16 | const after = yaml` 17 | paths: 18 | "/pet/{id}": 19 | get: {} 20 | "/pet/findById": 21 | get: {} 22 | ` 23 | const { diffs, merged } = compareOpenApi(before, after, { metaKey }) 24 | 25 | expect(diffs.length).toEqual(3) 26 | for (const diff of diffs) { 27 | expect(diff).toHaveProperty("description") 28 | expect(diff.description).not.toEqual("") 29 | expect(diff.type).not.toEqual("unclassified") 30 | } 31 | 32 | expect(merged.paths["/pet"][metaKey]).toMatchObject({ 33 | get: { action: DiffAction.remove, type: breaking }, 34 | post: { action: DiffAction.remove, type: breaking }, 35 | }) 36 | expect(merged.paths["/pet/{id}"][metaKey]).toMatchObject({ 37 | get: { action: DiffAction.add, type: nonBreaking }, 38 | }) 39 | }) 40 | 41 | it("should comapre operations with different annotations", () => { 42 | const before = yaml` 43 | paths: 44 | "/pet": 45 | get: 46 | summary: Get list of pets 47 | security: 48 | - petstore_auth: 49 | - read:pets 50 | x-codegen-request-body-name: body 51 | ` 52 | const after = yaml` 53 | paths: 54 | "/pet": 55 | summary: operation with Pet 56 | get: 57 | tags: 58 | - pet 59 | operationId: getPet 60 | deprecated: true 61 | security: 62 | - petstore_auth: 63 | - write:pets 64 | - read:pets 65 | x-codegen-request-body-name: test 66 | ` 67 | const { diffs, merged } = compareOpenApi(before, after, { metaKey }) 68 | 69 | expect(diffs.length).toEqual(6) 70 | 71 | for (const diff of diffs) { 72 | expect(diff).toHaveProperty("description") 73 | expect(diff.description).not.toEqual("") 74 | expect(diff.type).not.toEqual("unclassified") 75 | } 76 | 77 | expect(merged.paths["/pet"].get[metaKey]).toMatchObject({ 78 | deprecated: { action: "add", type: "deprecated" }, 79 | operationId: { action: "add", type: "annotation" }, 80 | tags: { array: { 0: { action: "add", type: "annotation" } } }, 81 | summary: { action: "replace", type: "annotation", replaced: "Get list of pets" }, 82 | "x-codegen-request-body-name": { action: "replace", type: "annotation", replaced: "body" }, 83 | }) 84 | expect(merged.paths["/pet"].get.security[0][metaKey]).toMatchObject({ 85 | petstore_auth: { array: { 1: { action: "add", type: "non-breaking" } } }, 86 | }) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /test/openapi/responses.test.ts: -------------------------------------------------------------------------------- 1 | import { DiffAction, annotation, breaking, compareOpenApi, nonBreaking, unclassified } from "../../src" 2 | import { yaml } from "../helpers" 3 | 4 | const metaKey = Symbol("diff") 5 | 6 | describe("Openapi response changes", () => { 7 | it("should compare requestBody", () => { 8 | const before = yaml` 9 | paths: 10 | "/pet/findByStatus": 11 | get: 12 | responses: 13 | '200': 14 | description: successful operation 15 | content: 16 | application/xml: 17 | schema: 18 | type: array 19 | items: 20 | "$ref": "#/components/schemas/Pet" 21 | application/json: 22 | schema: 23 | type: array 24 | items: 25 | "$ref": "#/components/schemas/Pet" 26 | '400': 27 | description: Invalid status value 28 | content: {} 29 | components: 30 | schemas: 31 | Pet: 32 | type: object 33 | required: 34 | - id 35 | - name 36 | properties: 37 | id: 38 | type: integer 39 | format: int64 40 | name: 41 | type: string 42 | example: doggie 43 | xml: 44 | name: Pet 45 | ` 46 | 47 | const after = yaml` 48 | paths: 49 | "/pet/findByStatus": 50 | get: 51 | responses: 52 | '200': 53 | x-test: true 54 | description: successful operation 55 | content: 56 | application/json: 57 | encoding: 58 | contentType: application/json 59 | examples: 60 | cat: 61 | - id: 5 62 | name: cat 63 | - id: 6 64 | name: cat 65 | schema: 66 | type: array 67 | items: 68 | "$ref": "#/components/schemas/Pet" 69 | '400': 70 | description: Invalid status value 71 | content: {} 72 | '404': 73 | description: Not found 74 | content: {} 75 | components: 76 | schemas: 77 | Pet: 78 | type: object 79 | required: 80 | - id 81 | - name 82 | properties: 83 | id: 84 | type: integer 85 | name: 86 | type: string 87 | example: doggie 88 | xml: 89 | name: Pet 90 | ` 91 | 92 | const { diffs, merged } = compareOpenApi(before, after, { metaKey }) 93 | 94 | expect(diffs.length).toEqual(7) 95 | diffs.forEach((diff, i) => { 96 | i !== 6 && expect(diff).toHaveProperty("description") 97 | i !== 6 && expect(diff.description).not.toEqual("") 98 | i !== 4 && expect(diff.type).not.toEqual("unclassified") 99 | }) 100 | 101 | expect(merged.paths["/pet/findByStatus"].get.responses[metaKey]).toMatchObject({ 102 | "404": { action: DiffAction.add, type: nonBreaking }, 103 | }) 104 | 105 | expect(merged.paths["/pet/findByStatus"].get.responses[200][metaKey]).toMatchObject({ 106 | "x-test": { action: DiffAction.add, type: unclassified }, 107 | }) 108 | 109 | expect(merged.paths["/pet/findByStatus"].get.responses[200].content[metaKey]).toMatchObject({ 110 | "application/xml": { action: DiffAction.remove, type: breaking }, 111 | }) 112 | 113 | expect(merged.paths["/pet/findByStatus"].get.responses[200].content["application/json"][metaKey]).toMatchObject({ 114 | encoding: { action: DiffAction.add, type: breaking }, 115 | examples: { action: DiffAction.add, type: annotation }, 116 | }) 117 | 118 | expect( 119 | merged.paths["/pet/findByStatus"].get.responses[200].content["application/json"].schema.items.properties.id[ 120 | metaKey 121 | ], 122 | ).toMatchObject({ 123 | format: { action: DiffAction.remove, type: breaking }, 124 | }) 125 | 126 | expect(merged.components.schemas.Pet.properties.id[metaKey]).toMatchObject({ 127 | format: { action: DiffAction.remove, type: nonBreaking }, 128 | }) 129 | }) 130 | 131 | it("should be non-breaking change to add any property to response", () => { 132 | const before = yaml` 133 | paths: 134 | "/pet/findByStatus": 135 | get: 136 | responses: 137 | '200': 138 | description: successful operation 139 | content: 140 | application/json: 141 | schema: 142 | type: object 143 | required: 144 | - id 145 | - name 146 | properties: 147 | id: 148 | type: integer 149 | name: 150 | type: string 151 | ` 152 | const after = yaml` 153 | paths: 154 | "/pet/findByStatus": 155 | get: 156 | responses: 157 | '200': 158 | description: successful operation 159 | content: 160 | application/json: 161 | schema: 162 | type: object 163 | required: 164 | - id 165 | - name 166 | - test2 167 | properties: 168 | id: 169 | type: integer 170 | name: 171 | type: string 172 | test1: 173 | type: string 174 | test2: 175 | type: string 176 | ` 177 | 178 | const { diffs, merged } = compareOpenApi(before, after, { metaKey }) 179 | 180 | expect(diffs.length).toEqual(3) 181 | for (const diff of diffs) { 182 | expect(diff).toHaveProperty("description") 183 | expect(diff.description).not.toEqual("") 184 | expect(diff.type).not.toEqual("unclassified") 185 | } 186 | 187 | expect( 188 | merged.paths["/pet/findByStatus"].get.responses["200"].content["application/json"].schema[metaKey], 189 | ).toMatchObject({ 190 | required: { array: { 2: { action: DiffAction.add, type: nonBreaking } } }, 191 | }) 192 | 193 | expect( 194 | merged.paths["/pet/findByStatus"].get.responses["200"].content["application/json"].schema.properties[metaKey], 195 | ).toMatchObject({ 196 | test1: { action: DiffAction.add, type: nonBreaking }, 197 | test2: { action: DiffAction.add, type: nonBreaking }, 198 | }) 199 | }) 200 | 201 | it("should be breaking change to remove required property from response", () => { 202 | const before = yaml` 203 | paths: 204 | "/pet/findByStatus": 205 | get: 206 | responses: 207 | '200': 208 | description: successful operation 209 | content: 210 | application/json: 211 | schema: 212 | type: object 213 | required: 214 | - id 215 | - name 216 | properties: 217 | id: 218 | type: integer 219 | name: 220 | type: string 221 | test: 222 | type: string 223 | ` 224 | const after = yaml` 225 | paths: 226 | "/pet/findByStatus": 227 | get: 228 | responses: 229 | '200': 230 | description: successful operation 231 | content: 232 | application/json: 233 | schema: 234 | type: object 235 | required: 236 | - id 237 | properties: 238 | id: 239 | type: integer 240 | ` 241 | 242 | const { diffs, merged } = compareOpenApi(before, after, { metaKey }) 243 | 244 | expect(diffs.length).toEqual(3) 245 | for (const diff of diffs) { 246 | expect(diff).toHaveProperty("description") 247 | expect(diff.description).not.toEqual("") 248 | expect(diff.type).not.toEqual("unclassified") 249 | } 250 | 251 | expect( 252 | merged.paths["/pet/findByStatus"].get.responses["200"].content["application/json"].schema[metaKey], 253 | ).toMatchObject({ 254 | required: { array: { 1: { action: DiffAction.remove, type: breaking } } }, 255 | }) 256 | 257 | expect( 258 | merged.paths["/pet/findByStatus"].get.responses["200"].content["application/json"].schema.properties[metaKey], 259 | ).toMatchObject({ 260 | name: { action: DiffAction.remove, type: breaking }, 261 | test: { action: DiffAction.remove, type: nonBreaking }, 262 | }) 263 | }) 264 | }) 265 | -------------------------------------------------------------------------------- /test/resources/externalref.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | paths: 3 | /inventory: 4 | get: 5 | summary: Returns inventory 6 | operationId: getInventory 7 | responses: 8 | '200': 9 | description: successful operation 10 | content: 11 | application/json: 12 | schema: 13 | $ref: '#/components/schemas/Inventory' 14 | components: 15 | schemas: 16 | Status: 17 | type: string 18 | enum: 19 | - available 20 | - reserved 21 | - sold 22 | Inventory: 23 | type: object 24 | properties: 25 | id: 26 | type: string 27 | group: 28 | $ref: "#/components/schemas/Group" 29 | status: 30 | $ref: "#/components/schemas/Status" 31 | extra_info: 32 | $ref: "common.yaml#/components/schemas/Info" 33 | Group: 34 | type: object 35 | properties: 36 | name: 37 | type: string 38 | items: 39 | type: array 40 | items: 41 | $ref: "#/components/schemas/Inventory" 42 | parent: 43 | $ref: "#/components/schemas/Group" 44 | -------------------------------------------------------------------------------- /test/resources/jsonschema.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | required: 3 | - pet_type 4 | properties: 5 | name: 6 | $ref: "#/$refs/NameType" 7 | pet_type: 8 | nullable: false 9 | type: string 10 | age: 11 | type: number 12 | title: age 13 | minimum: 10 14 | maximum: 20 15 | multipleOf: 2 16 | exclusiveMaximum: true 17 | foo: 18 | type: object 19 | properties: 20 | bar: 21 | $ref: "#/$refs/NameType" 22 | baz: 23 | type: number 24 | enum: [10,20,30,40] 25 | arr: 26 | type: array 27 | items: 28 | type: number 29 | $refs: 30 | NameType: 31 | type: string -------------------------------------------------------------------------------- /test/resources/schema-after.graphql: -------------------------------------------------------------------------------- 1 | """""" 2 | directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT | INTERFACE 3 | 4 | """""" 5 | type Query2 { 6 | """""" 7 | continents: [Continent] 8 | """""" 9 | continent(code: String): Continent 10 | """""" 11 | countries: [Country] 12 | """""" 13 | country(code: String): Country 14 | """""" 15 | languages: [Language] 16 | """""" 17 | language(code: String): Language 18 | } 19 | 20 | """""" 21 | type Continent { 22 | """""" 23 | code: String 24 | """""" 25 | name: String 26 | """""" 27 | countries: [Country] 28 | } 29 | 30 | """""" 31 | type Country { 32 | """""" 33 | code: String 34 | """""" 35 | name: String 36 | """""" 37 | native: String 38 | """""" 39 | phone: String 40 | """""" 41 | continent: Continent 42 | """""" 43 | currency: String 44 | """""" 45 | languages: [Language] 46 | """""" 47 | emoji: String 48 | """""" 49 | emojiU: String 50 | """""" 51 | states: [State] 52 | } 53 | 54 | """""" 55 | type Language { 56 | """""" 57 | code: String 58 | """""" 59 | name: String 60 | """""" 61 | native: String 62 | """""" 63 | rtl: Int 64 | } 65 | 66 | """""" 67 | type State { 68 | """""" 69 | code: String 70 | """""" 71 | name: String 72 | """""" 73 | country: Country 74 | } 75 | 76 | """""" 77 | enum CacheControlScope { 78 | """""" 79 | PUBLIC 80 | """""" 81 | PRIVATE 82 | } 83 | 84 | """The `Upload` scalar type represents a file upload.""" 85 | scalar Upload 86 | 87 | type Tweet { 88 | id: ID 89 | # The tweet text. No more than 140 characters! 90 | body: String 91 | # When the tweet was published 92 | date: Date 93 | # Who published the tweet 94 | Author: User 95 | # Views, retweets, likes, etc 96 | Stats: Stat 97 | } 98 | 99 | type User { 100 | id: ID! 101 | username: String 102 | first_name: String 103 | full_name: String 104 | name: String @deprecated 105 | last_name: String 106 | avatar_url: Url 107 | } 108 | 109 | type Stat { 110 | views: Int 111 | retweets: Int 112 | likes: Int 113 | responses: Int 114 | } 115 | 116 | type Notification { 117 | id: ID 118 | date: Date 119 | type: String 120 | } 121 | 122 | type Meta { 123 | count: Int 124 | } 125 | 126 | scalar Url 127 | scalar Date 128 | 129 | type Query { 130 | Tweet(id: ID!): Tweet 131 | Tweets(limit: Int, skip: Int, sort_field: String, sort_order: String): [Tweet] 132 | TweetsMeta: Meta 133 | User(id: ID!): User 134 | Notifications(limit: Int): [Notification] 135 | } 136 | 137 | type Mutation { 138 | createTweet ( 139 | body: String 140 | ): Tweet 141 | deleteTweet(id: ID!): Tweet 142 | markTweetRead(id: ID!): Boolean 143 | } 144 | -------------------------------------------------------------------------------- /test/resources/schema-after.yaml: -------------------------------------------------------------------------------- 1 | title: User 2 | type: object 3 | properties: 4 | name: 5 | type: string 6 | const: Constant name 7 | examples: 8 | - Different name 9 | description: The user's full name. This description can be long and should truncate 10 | once it reaches the end of the row. If it's not truncating then theres and issue 11 | that needs to be fixed. Help! 12 | age: 13 | type: string 14 | minimum: 20 15 | maximum: 30 16 | multipleOf: 10 17 | default: 20 18 | enum: 19 | - 10 20 | - 20 21 | - 30 22 | - 40 23 | - 50 24 | readOnly: false 25 | completed_at: 26 | type: string 27 | format: uuid 28 | writeOnly: true 29 | pattern: "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]123$" 30 | description: "* Completed At 1 \n * Completed At 1" 31 | items: 32 | type: 33 | - 'null' 34 | - array 35 | items: 36 | type: 37 | - string 38 | - number 39 | minItems: 1 40 | maxItems: 4 41 | description: This can be long and should truncate once it reaches the end of the 42 | row. If it's not truncating then theres and issue that needs to be fixed. Help! 43 | email: 44 | type: string 45 | format: email 46 | deprecated: false 47 | default: default@email1.com 48 | minLength: 2 49 | plan: 50 | anyOf: 51 | - type: string 52 | - type: object 53 | properties: 54 | foo: 55 | type: string 56 | bar: 57 | type: number 58 | baz: 59 | type: boolean 60 | deprecated: false 61 | example: hi 62 | description: "- Plan! \n - Plan!" 63 | required: 64 | - foo 65 | - bar 66 | - type: array 67 | description: test 68 | items: 69 | type: integer 70 | permissions: 71 | type: 72 | - string 73 | - object 74 | properties: 75 | ids12: 76 | type: array 77 | items: 78 | type: integer 79 | ref: 80 | "$ref": "#/properties/permissions" 81 | patternProperties: 82 | "^id_": 83 | type: number 84 | foo: 85 | type: integer 86 | _name$: 87 | type: number 88 | required: 89 | - name 90 | - age 91 | -------------------------------------------------------------------------------- /test/resources/schema-before.graphql: -------------------------------------------------------------------------------- 1 | """""" 2 | directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT | INTERFACE 3 | 4 | """""" 5 | type Query2 { 6 | """""" 7 | continents: [Continent]! 8 | """""" 9 | continent(code: String): Continent 10 | """""" 11 | countries: [Country] 12 | """""" 13 | country(code: String): Country 14 | """""" 15 | languages: [Language] 16 | """""" 17 | language(code: String): Language 18 | } 19 | 20 | """""" 21 | type Continent { 22 | """""" 23 | code: String 24 | """""" 25 | name: String 26 | """""" 27 | countries: [Country] 28 | } 29 | 30 | """""" 31 | type Country { 32 | """""" 33 | code: ID 34 | """""" 35 | name: String 36 | """""" 37 | native: String 38 | """""" 39 | phone: String 40 | """""" 41 | continent: Continent 42 | """""" 43 | currency: String 44 | """""" 45 | languages: [Language] 46 | """""" 47 | emoji: String 48 | """""" 49 | emojiU: String 50 | """ some text """ 51 | states: [State] 52 | } 53 | 54 | """""" 55 | type Language { 56 | """""" 57 | code: ID 58 | """""" 59 | name: String 60 | """""" 61 | native: String 62 | """""" 63 | rtl: Int 64 | } 65 | 66 | """""" 67 | type State { 68 | """""" 69 | code: String 70 | """""" 71 | name: String 72 | """""" 73 | country: Country 74 | } 75 | 76 | """""" 77 | enum CacheControlScope { 78 | """""" 79 | PUBLIC 80 | """""" 81 | PRIVATE 82 | } 83 | 84 | """The `Upload` scalar type represents a file upload.""" 85 | scalar Upload 86 | 87 | type Tweet { 88 | id: ID! 89 | # The tweet text. No more than 150 characters! 90 | body: String 91 | # When the tweet was published 92 | date: Date 93 | # Who published the tweet 94 | Author: User 95 | # Views, retweets, likes, etc 96 | Stats: Stat 97 | } 98 | 99 | type User { 100 | id: ID! 101 | username: String 102 | first_name: String 103 | last_name: String 104 | full_name: String 105 | name: String @deprecated 106 | avatar_url: Url 107 | } 108 | 109 | type Stat { 110 | views: Int 111 | likes: Int 112 | retweets: Int 113 | } 114 | 115 | type Notification { 116 | id: ID 117 | date: Date 118 | type: String 119 | } 120 | 121 | type Meta { 122 | count: Int 123 | } 124 | 125 | scalar Url 126 | scalar Date 127 | 128 | type Query { 129 | Tweet(id: ID!): Tweet 130 | Tweets(limit: Int, skip: Int, sort_field: String, sort_order: String): [Tweet] 131 | TweetsMeta: Meta 132 | User(id: ID!): User 133 | Notifications(limit: Int): [Notification] 134 | NotificationsMeta: Meta 135 | } 136 | 137 | type Mutation { 138 | createTweet ( 139 | body: String 140 | ): Tweet 141 | deleteTweet(id: ID!): Tweet 142 | markTweetRead(id: ID!): Boolean 143 | } 144 | -------------------------------------------------------------------------------- /test/resources/schema-before.yaml: -------------------------------------------------------------------------------- 1 | title: User 2 | type: object 3 | properties: 4 | name: 5 | type: string 6 | const: Constant name 7 | examples: 8 | - Example name 9 | - Different name 10 | description: The user's full name. This description can be long and should truncate 11 | once it reaches the end of the row. If it's not truncating then theres and issue 12 | that needs to be fixed. Help! 13 | age: 14 | type: number 15 | minimum: 10 16 | maximum: 40 17 | multipleOf: 10 18 | x-param: qqwertyui 19 | default: 20 20 | enum: 21 | - 10 22 | - 30 23 | - 20 24 | - 40 25 | readOnly: true 26 | completed_at: 27 | type: string 28 | format: date-time 29 | writeOnly: true 30 | pattern: "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$" 31 | description: "* Completed At 1 \n * Completed At 1 \n * Completed At 1 " 32 | items: 33 | type: 34 | - 'null' 35 | - array 36 | items: 37 | type: 38 | - string 39 | - number 40 | minItems: 1 41 | maxItems: 4 42 | description: This description can be long and should truncate once it reaches 43 | the end of the row. If it's not truncating then theres and issue that needs 44 | to be fixed. Help! 45 | email: 46 | type: string 47 | format: email 48 | deprecated: true 49 | default: default@email.com 50 | minLength: 2 51 | plan: 52 | anyOf: 53 | - type: object 54 | properties: 55 | foo: 56 | type: string 57 | bar: 58 | type: string 59 | baz: 60 | type: string 61 | deprecated: false 62 | example: hi 63 | description: "- Plan! \n - Plan!" 64 | required: 65 | - foo 66 | - bar 67 | - type: array 68 | items: 69 | type: integer 70 | - type: number 71 | - type: string 72 | 73 | permissions: 74 | type: 75 | - string 76 | - object 77 | properties: 78 | ids: 79 | type: array 80 | items: 81 | type: integer 82 | ref: 83 | "$ref": "#/properties/permissions" 84 | patternProperties: 85 | "^id_": 86 | type: number 87 | foo: 88 | type: integer 89 | _name$: 90 | type: string 91 | required: 92 | - name 93 | - age 94 | - completed_at 95 | -------------------------------------------------------------------------------- /test/rules.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CompareRules, 3 | breaking, 4 | nonBreaking, 5 | reverseClassifyRuleTransformer, 6 | transformComapreRules, 7 | unclassified, 8 | } from "../src" 9 | 10 | describe("Diff type classify transformer tests", () => { 11 | it("should reverse simple rules", () => { 12 | const rules: CompareRules = { 13 | "/*": { 14 | $: [nonBreaking, breaking, unclassified], 15 | }, 16 | } 17 | const reversed = transformComapreRules(rules, reverseClassifyRuleTransformer) 18 | expect(reversed).toMatchObject({ 19 | "/*": { $: [breaking, nonBreaking, unclassified] }, 20 | }) 21 | }) 22 | 23 | it("should reverse rules with nested rules", () => { 24 | const rules: CompareRules = { 25 | "/*": { 26 | $: [nonBreaking, breaking, unclassified], 27 | "/*": { 28 | $: [breaking, nonBreaking, unclassified], 29 | }, 30 | }, 31 | } 32 | 33 | const reversed = transformComapreRules(rules, reverseClassifyRuleTransformer) 34 | 35 | expect(reversed).toMatchObject({ 36 | "/*": { 37 | $: [breaking, nonBreaking, unclassified], 38 | "/*": { 39 | $: [nonBreaking, breaking, unclassified], 40 | }, 41 | }, 42 | }) 43 | }) 44 | 45 | it("should reverse rules with classifyFunc ", () => { 46 | const rules: CompareRules = { 47 | "/*": { 48 | $: [nonBreaking, breaking, ({ before }) => (before.path.length ? breaking : nonBreaking)], 49 | }, 50 | } 51 | 52 | const reversed: any = transformComapreRules(rules, reverseClassifyRuleTransformer) 53 | 54 | expect(reversed["/*"].$[2]({ before: { path: [] } })).toEqual(breaking) 55 | expect(reversed["/*"].$[2]({ before: { path: [1] } })).toEqual(nonBreaking) 56 | }) 57 | 58 | it("should reverse rules with nested func rules", () => { 59 | const rules: CompareRules = { 60 | "/*": { 61 | $: [nonBreaking, breaking, unclassified], 62 | "/*": () => ({ 63 | $: [breaking, nonBreaking, unclassified], 64 | }), 65 | }, 66 | } 67 | 68 | const reversed: any = transformComapreRules(rules, reverseClassifyRuleTransformer) 69 | 70 | expect(reversed["/*"]["/*"]()).toMatchObject({ 71 | $: [nonBreaking, breaking, unclassified], 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "esnext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": ["es2019"], /* Specify library files to be included in the compilation. */ 10 | "allowJs": false /* Allow javascript files to be compiled. */, 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true /* Generates corresponding '.d.ts' file. */, 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "dist" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | "typeRoots": ["types", "node_modules/@types"] /* List of folders to include type definitions from. */, 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "pretty": true, 59 | "sourceMap": true, 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true /* Skip type checking of declaration files. */, 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | }, 71 | "include": ["./src/**/*"], 72 | "exclude": ["node_modules", "dist"] 73 | } 74 | --------------------------------------------------------------------------------