diff --git a/README.md b/README.md index ae51534..1131cfc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ npm npm ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/udamir/allof-merge/ci.yml) npm type definitions ![Coveralls branch](https://img.shields.io/coverallsCoverage/github/udamir/allof-merge) GitHub -Merge schemas combined using allOf into a more readable composed schema free from allOf. +Merge schemas using allOf into a more readable composed schema free from allOf. ## Features - Safe merging of schemas combined with allOf in whole JsonSchema based document @@ -13,7 +13,8 @@ Merge schemas combined using allOf into a more readable composed schema free fro - Correctly merge items and additionalItems taking into account common validations - Supports rules extension to merge other document types and JsonSchema versions - Supports $refs and circular references either (internal references only) -- Correctly merge of sibling content with $refs (optionally) +- Correctly merge of $refs with sibling content (optionally) +- Correctly merge of combinaries (anyOf, oneOf) with sibling content (optionally) - Typescript syntax support out of the box - No dependencies (except json-crawl), can be used in nodejs or browser @@ -107,10 +108,14 @@ interface MergeOptions { // (optional) default = jsonSchemaMergeRules("draft-06") rules?: MergeRules - // merge $ref and sibling content + // merge $ref with sibling content // (optional) default = false mergeRefSibling?: boolean + // merge anyOf/oneOf with sibling content + // (optional) default = false + mergeCombinarySibling?: boolean + // Merge error hook, called on any merge conflicts onMergeError?: (message: string, path: JsonPath, values: any[]) => void } diff --git a/package.json b/package.json index f49e328..0c408a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "allof-merge", - "version": "0.3.0", + "version": "0.4.0", "description": "Simplify your JsonSchema by combining allOf safely.", "module": "dist/esm/index.js", "main": "dist/cjs/index.js", diff --git a/src/merge.ts b/src/merge.ts index a17d007..47d0f57 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -1,6 +1,6 @@ import { JsonPath, SyncCloneHook, isObject, syncClone } from "json-crawl" -import { buildPointer, isRefNode, parseRef, removeDuplicates, resolvePointer } from "./utils" +import { buildPointer, isAnyOfNode, isOneOfNode, isRefNode, parseRef, removeDuplicates, resolvePointer } from "./utils" import { MergeError, MergeOptions, MergeRules } from "./types" import { jsonSchemaMergeResolver } from "./resolvers" import { jsonSchemaMergeRules } from "./rules" @@ -82,12 +82,29 @@ export const allOfResolverHook = (options?: MergeOptions): SyncCloneHook<{}> => const _allOf = [...allOf] - if (!_allOf.length && !(options?.mergeRefSibling && isRefNode(value))) { - return { value, exitHook } + if (!_allOf.length) { + if (options?.mergeRefSibling && isRefNode(value)) { + // create allOf from $ref and sibling if mergeRefSibling option + _allOf.push(sibling) + } else if (options?.mergeCombinarySibling && isAnyOfNode(value)) { + // include sibling to anyOf if mergeCombinarySibling option + const { anyOf, ...rest } = value + const _value = Object.keys(rest).length ? { anyOf: anyOf.map((item) => ({ allOf: [item, rest] })) } : value + return { value: _value, exitHook } + } else if (options?.mergeCombinarySibling && isOneOfNode(value)) { + // include sibling to oneOf if mergeCombinarySibling option + const { oneOf, ...rest } = value + const _value = Object.keys(rest).length ? { oneOf: oneOf.map((item) => ({ allOf: [item, rest] })) } : value + return { value: _value, exitHook } + } + } else { + // include sibling to allOf + _allOf.push(sibling) } - // include sibling to allOf if mergeRefSibling option - _allOf.push(sibling) + if (!_allOf.length) { + return { value, exitHook } + } const allOfItems = normalizeAllOfItems(_allOf, source, allOfRefs, buildPointer(ctx.path)) diff --git a/src/types.ts b/src/types.ts index c2ca6aa..095bea3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,9 +4,10 @@ export type JsonSchema = any export type MergeRules = CrawlRules export interface MergeOptions { - source?: any // source JsonSchema if merging only part of it - rules?: MergeRules // custom merge rules - mergeRefSibling?: boolean // merge $ref and sibling content + source?: any // source JsonSchema if merging only part of it + rules?: MergeRules // custom merge rules + mergeRefSibling?: boolean // merge $ref and sibling content + mergeCombinarySibling?: boolean // merge oneOf, anyOf sibling content onMergeError?: (message: string, path: JsonPath, values: any[]) => void } @@ -15,6 +16,16 @@ export interface RefNode { [key: string]: any } +export interface AnyOfNode { + anyOf: any[] + [key: string]: any +} + +export interface OneOfNode { + oneOf: any[] + [key: string]: any +} + export type MergeError = (values: any[]) => void export interface MergeContext { diff --git a/src/utils.ts b/src/utils.ts index db8fd4d..dae0b09 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { JsonPath, isObject } from "json-crawl" -import { RefNode } from "./types" +import { AnyOfNode, OneOfNode, RefNode } from "./types" export class MapArray extends Map> { public add(key: K, value: V): this { @@ -44,6 +44,14 @@ export const isRefNode = (value: any): value is RefNode => { return value && value.$ref && typeof value.$ref === "string" } +export const isAnyOfNode = (value: any): value is AnyOfNode => { + return value && value.anyOf && Array.isArray(value.anyOf) +} + +export const isOneOfNode = (value: any): value is OneOfNode => { + return value && value.oneOf && Array.isArray(value.oneOf) +} + export const parseRef = ($ref: string, basePath = "") => { const [filePath = basePath, ref] = $ref.split("#") diff --git a/test/merge.jsonschema.test.ts b/test/merge.jsonschema.test.ts index e2b73e4..417c2f4 100644 --- a/test/merge.jsonschema.test.ts +++ b/test/merge.jsonschema.test.ts @@ -343,164 +343,6 @@ describe("module", function () { }, }) }) - - it("should merge $ref sibling with mergeRefSibling option", () => { - const source = { - foo: { - type: "object", - properties: { - id: { - type: "string", - }, - }, - }, - bar: { - type: "object", - properties: { - permissions: { - $ref: "#/permission", - type: "object", - properties: { - admin: { - type: "boolean", - }, - }, - }, - }, - }, - permission: { - type: "object", - properties: { - level: { - type: "number", - }, - }, - }, - } - - const result = merge( - { - allOf: [ - { - $ref: "#/foo", - }, - { - $ref: "#/bar", - }, - { - type: "object", - properties: { - name: { - type: "string", - }, - }, - }, - ], - }, - { source, mergeRefSibling: true } - ) - - expect(result).toEqual({ - type: "object", - properties: { - id: { - type: "string", - }, - name: { - type: "string", - }, - permissions: { - type: "object", - properties: { - admin: { - type: "boolean", - }, - level: { - type: "number", - }, - }, - }, - }, - }) - }) - - it("should not merge $ref sibling without mergeRefSibling option", () => { - const source = { - foo: { - type: "object", - properties: { - id: { - type: "string", - }, - }, - }, - bar: { - type: "object", - properties: { - permissions: { - $ref: "#/permission", - type: "object", - properties: { - admin: { - type: "boolean", - }, - }, - }, - }, - }, - permission: { - type: "object", - properties: { - level: { - type: "number", - }, - }, - }, - } - - const result = merge( - { - allOf: [ - { - $ref: "#/foo", - }, - { - $ref: "#/bar", - }, - { - type: "object", - properties: { - name: { - type: "string", - }, - }, - }, - ], - }, - { source } - ) - - expect(result).toEqual({ - type: "object", - properties: { - id: { - type: "string", - }, - name: { - type: "string", - }, - permissions: { - $ref: "#/permission", - type: "object", - properties: { - admin: { - type: "boolean", - }, - }, - }, - }, - }) - }) }) describe("simple resolve functionality", function () { diff --git a/test/merge.sibling.test.ts b/test/merge.sibling.test.ts new file mode 100644 index 0000000..9c22650 --- /dev/null +++ b/test/merge.sibling.test.ts @@ -0,0 +1,218 @@ +import { merge } from "../src" + +describe("merge sibling content", function () { + it("should merge $ref sibling with mergeRefSibling option", () => { + const source = { + foo: { + type: "object", + properties: { + id: { + type: "string", + }, + }, + }, + bar: { + type: "object", + properties: { + permissions: { + $ref: "#/permission", + type: "object", + properties: { + admin: { + type: "boolean", + }, + }, + }, + }, + }, + permission: { + type: "object", + properties: { + level: { + type: "number", + }, + }, + }, + } + + const result = merge( + { + allOf: [ + { + $ref: "#/foo", + }, + { + $ref: "#/bar", + }, + { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + ], + }, + { source, mergeRefSibling: true } + ) + + expect(result).toEqual({ + type: "object", + properties: { + id: { + type: "string", + }, + name: { + type: "string", + }, + permissions: { + type: "object", + properties: { + admin: { + type: "boolean", + }, + level: { + type: "number", + }, + }, + }, + }, + }) + }) + + it("should not merge $ref sibling without mergeRefSibling option", () => { + const source = { + foo: { + type: "object", + properties: { + id: { + type: "string", + }, + }, + }, + bar: { + type: "object", + properties: { + permissions: { + $ref: "#/permission", + type: "object", + properties: { + admin: { + type: "boolean", + }, + }, + }, + }, + }, + permission: { + type: "object", + properties: { + level: { + type: "number", + }, + }, + }, + } + + const result = merge( + { + allOf: [ + { + $ref: "#/foo", + }, + { + $ref: "#/bar", + }, + { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + ], + }, + { source } + ) + + expect(result).toEqual({ + type: "object", + properties: { + id: { + type: "string", + }, + name: { + type: "string", + }, + permissions: { + $ref: "#/permission", + type: "object", + properties: { + admin: { + type: "boolean", + }, + }, + }, + }, + }) + }) + + it("should merges oneOf and sibling with mergeCombinarySibling option", function () { + const result = merge({ + required: ["id"], + type: "object", + properties: { + id: { + type: "string", + }, + }, + oneOf: [ + { + type: "object", + properties: { + key: { + type: "string", + }, + }, + }, + { + type: "object", + additionalProperties: { + type: "string", + }, + }, + ], + }, { mergeCombinarySibling: true }) + + expect(result).toMatchObject({ + oneOf: [ + { + required: ["id"], + type: "object", + properties: { + id: { + type: "string", + }, + key: { + type: "string", + }, + }, + }, + { + required: ["id"], + type: "object", + properties: { + id: { + type: "string", + }, + }, + additionalProperties: { + type: "string", + }, + }, + ], + }) + }) +}) \ No newline at end of file