├── .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 |
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 |
--------------------------------------------------------------------------------