Skip to content

Commit

Permalink
bump json-crawl, add graphapi support, bugfix
Browse files Browse the repository at this point in the history
  • Loading branch information
udamir committed Dec 17, 2023
1 parent 616be3f commit a337824
Show file tree
Hide file tree
Showing 13 changed files with 454 additions and 64 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ Merge schemas using allOf into a more readable composed schema free from allOf.

- [JsonSchema](https://json-schema.org/draft/2020-12/json-schema-core.html)
- [OpenApi 3.x](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md)
- [GraphApi](https://github.com/udamir/graphapi)
- ~~Swagger 2.x~~ (roadmap)
- ~~AsyncApi 2.x~~ (roadmap)
- ~~AsyncApi 3.x~~ (roadmap)

## Other libraries
There are some libraries that can merge schemas combined with allOf. One of the most popular is [mokkabonna/json-schema-merge-allof](https://www.npmjs.com/package/json-schema-merge-allof), but it has some limitatons: Does not support circular $refs and no Typescript syntax out of the box.
Expand Down Expand Up @@ -128,6 +130,7 @@ interface MergeOptions {
You can find supported rules in the src/rules directory of this repository:
- `jsonSchemaMergeRules(version: "draft-04" | "draft-06")`
- `openapiMergeRules(version: "3.0.x" | "3.1.x")`
- `graphapiMergeRules`

## Benchmark
```
Expand Down
2 changes: 1 addition & 1 deletion benchmark/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { Suite } = require("benchmark")
const merger = require("json-schema-merge-allof")
const { merge } = require("../dist/cjs")
const { merge } = require("../dist/index.cjs")

const data = require("./data.json")

Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "allof-merge",
"version": "0.5.3",
"version": "0.6.0",
"description": "Simplify your JsonSchema by combining allOf safely.",
"module": "dist/index.mjs",
"main": "dist/index.cjs",
Expand Down Expand Up @@ -50,6 +50,8 @@
"@types/jest": "^29.5.2",
"@types/js-yaml": "^4.0.5",
"benchmark": "^2.1.4",
"gqlapi": "^0.5.1",
"graphql": "^16.8.1",
"jest": "^29.5.0",
"js-yaml": "^4.1.0",
"json-schema-merge-allof": "^0.8.1",
Expand Down Expand Up @@ -82,6 +84,6 @@
"collectCoverage": true
},
"dependencies": {
"json-crawl": "^0.2.6"
"json-crawl": "^0.4.2"
}
}
49 changes: 25 additions & 24 deletions src/merge.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { JsonPath, SyncCloneHook, isObject, syncClone } from "json-crawl"

import { AllOfRef, MergeError, MergeOptions, MergeRules } from "./types"
import { AllOfRef, MergeError, MergeOptions, MergeResolver, MergeRules } from "./types"
import { buildPointer, isAnyOfNode, isOneOfNode } from "./utils"
import { mergeCombinarySibling } from "./resolvers/combinary"
import { jsonSchemaMergeResolver } from "./resolvers"
Expand All @@ -13,8 +13,8 @@ export const merge = (value: any, options?: MergeOptions) => {
return syncClone(value, allOfResolverHook(options), { rules })
}

const isAllOfMergeRule = (rules: MergeRules) => {
return rules && rules["/allOf"] && "$" in rules["/allOf"]
const isAllOfMergeRule = (rules?: MergeRules): rules is { "/allOf": { $: MergeResolver }, [key: string]: MergeRules } => {
return !!rules && rules["/allOf"] && "$" in rules["/allOf"]
}

export const allOfResolverHook = (options?: MergeOptions): SyncCloneHook<{}> => {
Expand All @@ -29,32 +29,33 @@ export const allOfResolverHook = (options?: MergeOptions): SyncCloneHook<{}> =>
*/
const allOfRefs: AllOfRef[] = []

return (value, ctx) => {
return ({ value, key, path, rules, state }) => {
// save root value as source if source is not defined
if (!ctx.path.length && !options?.source) {
if (!path.length && !options?.source) {
source = value
}

const mergeError: MergeError = (values) => {
// check if merge error in anyOf/oneOf combination node
const args = findNodeToDelete(ctx.path)
const args = findNodeToDelete(path)
if (args) {
nodeToDelete.set(...args)
} else {
options?.onMergeError?.(ErrorMessage.mergeError(), ctx.path, values)
options?.onMergeError?.(ErrorMessage.mergeError(), path, values)
}
}

const exitHook = () => {
const { node } = ctx.state
const strPath = buildPointer(ctx.path)
const { node } = state
const strPath = buildPointer(path)
if (nodeToDelete.has(strPath)) {
const key = nodeToDelete.get(strPath)!
if (Array.isArray(node[ctx.key])) {
if (node[ctx.key].length < 2) {
const _key = nodeToDelete.get(strPath)!
const child = node[key]
if (Array.isArray(child)) {
if (child.length < 2) {
mergeError((<any>value)?.allOf || [])
}
node[ctx.key].splice(key, 1)
child.splice(_key, 1)
}
}
// if ("anyOf" in node) {
Expand All @@ -67,11 +68,11 @@ export const allOfResolverHook = (options?: MergeOptions): SyncCloneHook<{}> =>

// skip if not object
if (!isObject(value) || Array.isArray(value)) {
return { value, exitHook }
return { value: value, exitHook }
}

// check if in current node expected allOf merge rule in rules
if (!isAllOfMergeRule(ctx.rules)) { return { value, exitHook } }
if (!isAllOfMergeRule(rules)) { return { value, exitHook } }

const { allOf, ...sibling } = value

Expand All @@ -88,10 +89,10 @@ export const allOfResolverHook = (options?: MergeOptions): SyncCloneHook<{}> =>
_allOf.push({ $ref }, rest)
} else if (options?.mergeCombinarySibling) {
// create allOf from each combinary content and sibling if mergeCombinarySibling
if (isAnyOfNode(sibling) && ctx.rules["/anyOf"]) {
return { value: mergeCombinarySibling(sibling, "anyOf", ctx.rules["/anyOf"]), exitHook }
} else if (isOneOfNode(sibling) && ctx.rules["/oneOf"]) {
return { value: mergeCombinarySibling(sibling, "oneOf", ctx.rules["/oneOf"]), exitHook }
if (isAnyOfNode(sibling) && rules["/anyOf"]) {
return { value: mergeCombinarySibling(sibling, "anyOf", rules["/anyOf"]), exitHook }
} else if (isOneOfNode(sibling) && rules["/oneOf"]) {
return { value: mergeCombinarySibling(sibling, "oneOf", rules["/oneOf"]), exitHook }
}
}
} else if (Object.keys(sibling).length) {
Expand All @@ -102,10 +103,10 @@ export const allOfResolverHook = (options?: MergeOptions): SyncCloneHook<{}> =>
return { value: sibling, exitHook }
}

const { allOfItems, brokenRefs } = normalizeAllOfItems(_allOf, ctx.path, source, allOfRefs)
const { allOfItems, brokenRefs } = normalizeAllOfItems(_allOf, path, source, allOfRefs)

if (brokenRefs.length) {
brokenRefs.forEach((ref) => options?.onRefResolveError?.("Cannot resolve $ref", ctx.path, ref))
brokenRefs.forEach((ref) => options?.onRefResolveError?.("Cannot resolve $ref", path, ref))
return { value: { allOf: allOfItems }, exitHook }
}

Expand All @@ -114,12 +115,12 @@ export const allOfResolverHook = (options?: MergeOptions): SyncCloneHook<{}> =>
return { value: allOfItems.length ? allOfItems[0] : {}, exitHook }
}

const mergedNode = jsonSchemaMergeResolver(allOfItems, { allOfItems, mergeRules: ctx.rules, mergeError })
const mergedNode = jsonSchemaMergeResolver(allOfItems, { allOfItems, mergeRules: rules, mergeError })

if (options?.mergeCombinarySibling && isAnyOfNode(mergedNode)) {
return { value: mergeCombinarySibling(mergedNode, "anyOf", ctx.rules["/anyOf"]), exitHook }
return { value: mergeCombinarySibling(mergedNode, "anyOf", rules["/anyOf"]), exitHook }
} else if (options?.mergeCombinarySibling && isOneOfNode(mergedNode)) {
return { value: mergeCombinarySibling(mergedNode, "oneOf", ctx.rules["/oneOf"]), exitHook }
return { value: mergeCombinarySibling(mergedNode, "oneOf", rules["/oneOf"]), exitHook }
} else {
return { value: mergedNode, exitHook }
}
Expand Down
53 changes: 53 additions & 0 deletions src/rules/graphapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { jsonSchemaMergeRules } from "./jsonschema"
import * as resolvers from "../resolvers"
import { MergeRules } from "../types"

const graphSchemaMergeRules: MergeRules = jsonSchemaMergeRules({
"/args": () => graphSchemaMergeRules,
"/nullable": { $: resolvers.alternative },
"/specifiedByURL": { $: resolvers.last },
"/values": {
$: resolvers.mergeObjects,
"/*": {
$: resolvers.mergeObjects,
"/description": { $: resolvers.last },
"/deprecated": {
$: resolvers.last,
"/reason": { $: resolvers.last }
}
}
},
"/interfaces": {
$: resolvers.mergeObjects,
"/*": { $: resolvers.mergeObjects }
},
"/directives": {
$: resolvers.mergeObjects,
"/*": () => ({
...graphSchemaMergeRules,
"/meta": { $: resolvers.mergeObjects }
})
}
})

export const graphapiMergeRules: MergeRules = {
"/queries": {
"/*": () => graphSchemaMergeRules
},
"/mutations": {
"/*": () => graphSchemaMergeRules
},
"/subscriptions": {
"/*": () => graphSchemaMergeRules
},
"/components": {
"/*": {
"/*": graphSchemaMergeRules
},
"/directives": {
"/*": {
"/args": () => graphSchemaMergeRules,
},
},
}
}
3 changes: 2 additions & 1 deletion src/rules/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./jsonschema"
export * from "./openapi"
export * from "./openapi"
export * from "./graphapi"
48 changes: 25 additions & 23 deletions src/rules/jsonschema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const jsonSchemaVersion = ["draft-04", "draft-06"] as const

export type JsonSchemaVersion = typeof jsonSchemaVersion[number]

export const jsonSchemaMergeRules = (draft: JsonSchemaVersion = "draft-06"): MergeRules => ({
export const jsonSchemaMergeRules = (customRules: MergeRules = {}, draft: JsonSchemaVersion = "draft-06"): MergeRules => ({
"/maximum": { $: resolvers.minValue },
"/exclusiveMaximum": { $: resolvers.alternative },
"/minimum": { $: resolvers.maxValue },
Expand All @@ -22,73 +22,75 @@ export const jsonSchemaMergeRules = (draft: JsonSchemaVersion = "draft-06"): Mer
"/enum": { $: resolvers.mergeEnum },
"/type": { $: resolvers.mergeTypes },
"/allOf": {
"/*": () => jsonSchemaMergeRules(draft),
"/*": () => jsonSchemaMergeRules(customRules, draft),
$: resolvers.mergeArray,
},
"/not": { $: resolvers.mergeNot },
"/oneOf": {
"/*": () => jsonSchemaMergeRules(draft),
"/*": () => jsonSchemaMergeRules(customRules, draft),
$: resolvers.mergeArray,
sibling: draft === "draft-04" ? ["defs"] : ["definitions"],
sibling: ["definitions", "$defs", "$id", "$schema"],
},
"/anyOf": {
"/*": () => jsonSchemaMergeRules(draft),
"/*": () => jsonSchemaMergeRules(customRules, draft),
$: resolvers.mergeArray,
sibling: draft === "draft-04" ? ["defs"] : ["definitions"],
sibling: ["definitions", "$defs", "$id", "$schema"],
},
"/properties": {
"/*": () => jsonSchemaMergeRules(draft),
"/*": () => jsonSchemaMergeRules(customRules, draft),
$: resolvers.propertiesMergeResolver,
},
"/items": () => ({
...jsonSchemaMergeRules(draft),
"$": resolvers.itemsMergeResolver,
"/*": (path) => typeof path[path.length-1] === 'number' ? jsonSchemaMergeRules(draft) : {},
...jsonSchemaMergeRules(customRules, draft),
$: resolvers.itemsMergeResolver,
"/*": ({ key }) => typeof key === 'number' ? jsonSchemaMergeRules(customRules, draft) : {},
}),
"/additionalProperties": () => ({
...jsonSchemaMergeRules(draft),
...jsonSchemaMergeRules(customRules, draft),
"$": resolvers.additionalPropertiesMergeResolver
}),
"/additionalItems": () => ({
...jsonSchemaMergeRules(draft),
...jsonSchemaMergeRules(customRules, draft),
"$": resolvers.additionalItemsMergeResolver
}),
"/patternProperties": {
"/*": () => jsonSchemaMergeRules(draft),
"/*": () => jsonSchemaMergeRules(customRules, draft),
$: resolvers.propertiesMergeResolver,
},
"/pattern": { $: resolvers.mergePattern },
"/nullable": { $: resolvers.alternative },
// "/nullable": { $: resolvers.alternative },
"/readOnly": { $: resolvers.alternative },
"/writeOnly": { $: resolvers.alternative },
"/example": { $: resolvers.mergeObjects },
"/examples": { $: resolvers.mergeObjects },
"/deprecated": { $: resolvers.alternative },
...draft !== "draft-04" ? {
"/propertyNames": () => jsonSchemaMergeRules(draft),
"/contains": () => jsonSchemaMergeRules(draft),
"/propertyNames": () => jsonSchemaMergeRules(customRules, draft),
"/contains": () => jsonSchemaMergeRules(customRules, draft),
"/dependencies": {
"/*": () => jsonSchemaMergeRules(draft),
"/*": () => jsonSchemaMergeRules(customRules, draft),
$: resolvers.dependenciesMergeResolver
},
"/const": { $: resolvers.equal },
"/exclusiveMaximum": { $: resolvers.minValue },
"/exclusiveMinimum": { $: resolvers.maxValue },
"/definitions": {
'/*': () => jsonSchemaMergeRules(draft),
"/$defs": {
'/*': () => jsonSchemaMergeRules(customRules, draft),
$: resolvers.mergeObjects
},
} : {},
"/definitions": {
'/*': () => jsonSchemaMergeRules(customRules, draft),
$: resolvers.mergeObjects
},
"/xml": { $: resolvers.mergeObjects },
"/externalDocs": { $: resolvers.last },
"/description": { $: resolvers.last },
"/title": { $: resolvers.last },
"/format": { $: resolvers.last },
"/default": { $: resolvers.last },
"/?": { $: resolvers.last },
"/defs": {
'/*': () => jsonSchemaMergeRules(draft),
$: resolvers.mergeObjects
},
...customRules,

$: resolvers.jsonSchemaMergeResolver,
})
11 changes: 5 additions & 6 deletions src/rules/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ export const openApiVersion = ["3.0.x", "3.1.x"] as const

export type OpenApiVersion = typeof openApiVersion[number]

const customJsonSchemaMergeRules = (version: JsonSchemaVersion) => {
return {
...jsonSchemaMergeRules(version),
const customJsonSchemaMergeRules = (version: JsonSchemaVersion): MergeRules => {
return jsonSchemaMergeRules({
"/discriminator": { $: resolvers.mergeObjects },
"/oneOf": {
"/*": () => customJsonSchemaMergeRules(version),
Expand All @@ -20,14 +19,14 @@ const customJsonSchemaMergeRules = (version: JsonSchemaVersion) => {
$: resolvers.mergeArray,
sibling: ["discriminator"],
}
}
}, version)
}

export const openApiJsonSchemaMergeRules = (version: OpenApiVersion) => {
export const openApiJsonSchemaMergeRules = (version: OpenApiVersion): MergeRules => {
return version === "3.0.x"
? {
...customJsonSchemaMergeRules("draft-04"),
"/items": () => ({
"/items": ({ key }) => ({
...customJsonSchemaMergeRules("draft-04"),
"$": resolvers.itemsMergeResolver,
}),
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JsonPath, CrawlRules, CrawlContext } from "json-crawl"
import { JsonPath, CrawlRules } from "json-crawl"

export type JsonSchema = any
export type MergeRules = CrawlRules<MergeRule>
Expand Down Expand Up @@ -36,7 +36,7 @@ export interface MergeContext {
}

export type MergeResolver = (args: any[], ctx: MergeContext) => any
export type MergeRule = { "$": MergeResolver, sibling: string[] }
export type MergeRule = { "$"?: MergeResolver, sibling?: string[] }

export interface AllOfRef {
pointer: string
Expand Down
3 changes: 2 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ export const parseRef = ($ref: string, basePath = "") => {

const pointer = !ref || ref === "/" ? "" : ref
const normalized = createRef(filePath, pointer)
const jsonPath = parsePointer(pointer)

return { filePath, pointer, normalized }
return { filePath, pointer, normalized, jsonPath }
}

export const createRef = (basePath?: string, pointer?: string): string => {
Expand Down
Loading

0 comments on commit a337824

Please sign in to comment.