Skip to content

Commit 835abe7

Browse files
authored
Merge pull request #7 from cloudflare/merge
Adds $merge support
2 parents 47c6d81 + 1d3222b commit 835abe7

File tree

7 files changed

+185
-20
lines changed

7 files changed

+185
-20
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.1.2] - 2025-03-07
6+
7+
### Added
8+
9+
- $merge support - see README for more information
10+
511
## [0.1.1] - 2025-02-26
612

713
### Added

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ Cabidela takes a JSON-Schema and optional configuration flags:
7171
- `applyDefaults`: boolean - If true, the validator will apply default values to the input object. Default is false.
7272
- `errorMessages`: boolean - If true, the validator will use custom `errorMessage` messages from the schema. Default is false.
7373
- `fullErrors`: boolean - If true, the validator will be more verbose when throwing errors for complex schemas (example: anyOf, oneOf's), set to false for shorter exceptions. Default is true.
74+
- `useMerge`: boolean - Set to true if you want to use the `$merge` keyword. Default is false. See below for more information.
75+
- `subSchema`: any[] - An optional array of sub-schemas that can be used with `$id` and `$ref`. See below for more information.
7476

7577
Returns a validation object.
7678

@@ -249,6 +251,49 @@ cabidela.validate({
249251
});
250252
```
251253

254+
## Combined schemas and $merge
255+
256+
The standard way of combining and extending schemas is by using the [`allOf`](https://json-schema.org/understanding-json-schema/reference/combining#allOf) (AND), [`anyOf`](https://json-schema.org/understanding-json-schema/reference/combining#anyOf) (OR), [`oneOf`](https://json-schema.org/understanding-json-schema/reference/combining#oneOf) (XOR) and [`not`](https://json-schema.org/understanding-json-schema/reference/combining#not) keywords, all supported by this library.
257+
258+
Cabidela supports an additional keyword `$merge` (inspired by [Ajv](https://ajv.js.org/guide/combining-schemas.html#merge-and-patch-keywords)) that allows you to merge two objects. This is useful when you want to extend a schema with additional properties and `allOf`` is not enough.
259+
260+
Here's how it works:
261+
262+
```json
263+
{
264+
"$merge": {
265+
"source": {
266+
"type": "object",
267+
"properties": { "p": { "type": "string" } },
268+
"additionalProperties": false
269+
},
270+
"with": {
271+
"properties": { "q": { "type": "number" } }
272+
}
273+
}
274+
}
275+
```
276+
277+
Resolves to:
278+
279+
```json
280+
{
281+
"type": "object",
282+
"properties": {
283+
"q": {
284+
"type": "number"
285+
}
286+
},
287+
"additionalProperties": false
288+
}
289+
```
290+
291+
To use `$merge` set the `useMerge` flag to true when creating the instance.
292+
293+
```js
294+
new Cabidela(schema, { useMerge: true });
295+
```
296+
252297
## Custom errors
253298

254299
If the new instance options has the `errorMessages` flag set to true, you can use the property `errorMessage` in the schema to define custom error messages.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cloudflare/cabidela",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"description": "Cabidela is a small, fast, eval-less, Cloudflare Workers compatible, dynamic JSON Schema validator",
55
"main": "dist/index.js",
66
"module": "dist/index.mjs",
@@ -27,6 +27,9 @@
2727
"README.md",
2828
"CHANGELOG.md"
2929
],
30+
"prettier": {
31+
"embeddedLanguageFormatting": "auto"
32+
},
3033
"repository": {
3134
"type": "git",
3235
"url": "https://github.com/cloudflare/cabidela.git"
@@ -39,6 +42,7 @@
3942
"@vitest/ui": "^3.0.3",
4043
"ajv": "^8.17.1",
4144
"ajv-errors": "^3.0.0",
45+
"ajv-merge-patch": "^5.0.1",
4246
"tsup": "^8.3.6",
4347
"typescript": "^5.7.3",
4448
"vitest": "^3.0.3"

src/helpers.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { CabidelaOptions } from ".";
2+
13
export type metaData = {
24
types: Set<string>;
35
size: number;
@@ -21,24 +23,50 @@ export const parse$ref = (ref: string) => {
2123
return { $id, $path };
2224
};
2325

24-
export const traverseSchema = (definitions: any, obj: any, cb: any = () => {}) => {
25-
Object.keys(obj).forEach((key) => {
26-
if (obj[key] !== null && typeof obj[key] === "object") {
27-
traverseSchema(definitions, obj[key], (value: any) => {
28-
obj[key] = value;
29-
});
30-
} else {
31-
if (key === "$ref") {
32-
const { $id, $path } = parse$ref(obj[key]);
33-
const { resolvedObject } = resolvePayload($path, definitions[$id]);
34-
if (resolvedObject) {
35-
cb(resolvedObject);
36-
} else {
37-
throw new Error(`Could not resolve '${obj[key]}' $ref`);
26+
function deepMerge(target: any, source: any) {
27+
const result = Array.isArray(target) && Array.isArray(source) ? target.concat(source) : { ...target, ...source };
28+
for (const key of Object.keys(result)) {
29+
result[key] =
30+
typeof target[key] == "object" && typeof source[key] == "object"
31+
? deepMerge(target[key], source[key])
32+
: structuredClone(result[key]);
33+
}
34+
return result;
35+
}
36+
37+
export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: any, cb?: any) => {
38+
let hits: number;
39+
do {
40+
hits = 0;
41+
Object.keys(obj).forEach((key) => {
42+
if (obj[key] !== null && typeof obj[key] === "object") {
43+
traverseSchema(options, definitions, obj[key], (value: any) => {
44+
hits++;
45+
obj[key] = value;
46+
});
47+
if (options.useMerge && key === "$merge") {
48+
const merge = deepMerge(obj[key].source, obj[key].with);
49+
if (cb) {
50+
cb(merge);
51+
} else {
52+
// root level merge
53+
Object.assign(obj, merge);
54+
delete obj[key];
55+
}
56+
}
57+
} else {
58+
if (key === "$ref") {
59+
const { $id, $path } = parse$ref(obj[key]);
60+
const { resolvedObject } = resolvePayload($path, definitions[$id]);
61+
if (resolvedObject) {
62+
cb(resolvedObject);
63+
} else {
64+
throw new Error(`Could not resolve '${obj[key]}' $ref`);
65+
}
3866
}
3967
}
40-
}
41-
});
68+
});
69+
} while (hits > 0);
4270
};
4371

4472
/* Resolves a path in an object

src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { resolvePayload, pathToString, traverseSchema } from "./helpers";
22

33
export type CabidelaOptions = {
44
applyDefaults?: boolean;
5+
useMerge?: boolean;
56
errorMessages?: boolean;
67
fullErrors?: boolean;
78
subSchemas?: Array<any>;
@@ -41,7 +42,9 @@ export class Cabidela {
4142
for (const subSchema of this.options.subSchemas as []) {
4243
this.addSchema(subSchema, false);
4344
}
44-
traverseSchema(this.definitions, this.schema);
45+
}
46+
if (this.options.useMerge || (this.options.subSchemas as []).length > 0) {
47+
traverseSchema(this.options, this.definitions, this.schema);
4548
}
4649
}
4750

@@ -62,7 +65,7 @@ export class Cabidela {
6265
} else {
6366
throw new Error("subSchemas need $id https://json-schema.org/understanding-json-schema/structuring#id");
6467
}
65-
if (combine == true) traverseSchema(this.definitions, this.schema);
68+
if (combine == true) traverseSchema(this.options, this.definitions, this.schema);
6669
}
6770

6871
getSchema() {

tests/09-merge.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { expect, describe, test } from "vitest";
2+
import { FakeCabidela } from "./lib/fake-cabidela";
3+
4+
describe("$merge", () => {
5+
test.skipIf(process.env.AJV)("two objects", () => {
6+
let schema = {
7+
$merge: {
8+
source: {
9+
type: "object",
10+
properties: { p: { type: "string" } },
11+
additionalProperties: false,
12+
},
13+
with: {
14+
properties: { q: { type: "number" } },
15+
},
16+
},
17+
};
18+
const cabidela = new FakeCabidela(schema, { useMerge: true });
19+
schema = cabidela.getSchema();
20+
expect(schema).toStrictEqual({
21+
type: "object",
22+
properties: { p: { type: "string" }, q: { type: "number" } },
23+
additionalProperties: false,
24+
});
25+
});
26+
27+
test.skipIf(process.env.AJV)("two objects, with arrays", () => {
28+
let schema = {
29+
$merge: {
30+
source: {
31+
type: "object",
32+
properties: { p: [1, 2] },
33+
},
34+
with: {
35+
properties: { p: [3, 4] },
36+
},
37+
},
38+
};
39+
const cabidela = new FakeCabidela(schema, { useMerge: true });
40+
schema = cabidela.getSchema();
41+
expect(schema).toStrictEqual({
42+
type: "object",
43+
properties: { p: [1, 2, 3, 4] },
44+
});
45+
});
46+
47+
test.skipIf(process.env.AJV)("two objects, with $defs and $ref", () => {
48+
let schema = {
49+
$merge: {
50+
source: {
51+
type: "object",
52+
properties: { p: { type: "string" } },
53+
additionalProperties: false,
54+
},
55+
with: {
56+
properties: {
57+
q: {
58+
type: "string",
59+
maxLength: { $ref: "$defs#/max_tokens" },
60+
},
61+
},
62+
},
63+
},
64+
$defs: {
65+
max_tokens: 250,
66+
},
67+
};
68+
const cabidela = new FakeCabidela(schema, { useMerge: true });
69+
schema = cabidela.getSchema();
70+
expect(schema).toStrictEqual({
71+
type: "object",
72+
properties: { p: { type: "string" }, q: { type: "string", maxLength: 250 } },
73+
additionalProperties: false,
74+
});
75+
});
76+
});

tests/lib/fake-cabidela.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export class FakeCabidela {
2323
strict: false,
2424
useDefaults: this.options.applyDefaults ? true : false,
2525
});
26+
if (this.options.useMerge) {
27+
require("ajv-merge-patch")(this.ajv);
28+
}
2629
if (this.options.subSchemas) {
2730
this.options.subSchemas.forEach((subSchema: any) => {
2831
this.ajv.addSchema(subSchema);
@@ -67,7 +70,7 @@ export class FakeCabidela {
6770
const valid = this.validator(payload);
6871
if (!valid) {
6972
const description = this.validator.errors
70-
.map((e:any) => {
73+
.map((e: any) => {
7174
const instancePath = e.instancePath.split("/").join("");
7275
return instancePath === "" ? e.message : `'${instancePath}' ${e.message}`;
7376
})

0 commit comments

Comments
 (0)