Skip to content

Commit 67eea29

Browse files
authored
Merge pull request #5 from cloudflare/refs
Adds $ref and $id support
2 parents feb2224 + 1aebbbd commit 67eea29

File tree

11 files changed

+380
-24
lines changed

11 files changed

+380
-24
lines changed

.github/workflows/npm-publish-github-packages.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ jobs:
2828
- uses: actions/setup-node@v4
2929
with:
3030
node-version: 22.10.0
31-
registry-url: https://npm.pkg.github.com/
31+
registry-url: https://registry.npmjs.org/
3232
- run: npm install
3333
- run: npm run build
3434
- run: npm publish
3535
env:
36-
NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
36+
NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}}

CHANGELOG.md

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

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

5+
## [0.1.1] - 2025-02-26
6+
7+
### Added
8+
9+
- Added support for $id, $ref and $defs - https://json-schema.org/understanding-json-schema/structuring
10+
- Added support for not - https://json-schema.org/understanding-json-schema/reference/combining#not
11+
512
## [0.0.19] - 2025-02-26
613

714
### Added

README.md

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,78 @@ example, using this schema:
180180
- The payload `{ moon: 10}` will be modified to `{ sun: 9000, moon: 10 }`.
181181
- The payload `{ saturn: 10}` will throw an error because no condition is met.
182182

183+
### $id, $ref, $defs
184+
185+
The keywords [$id](https://json-schema.org/understanding-json-schema/structuring#id), [$ref](https://json-schema.org/understanding-json-schema/structuring#dollarref) and [$defs](https://json-schema.org/understanding-json-schema/structuring#defs) can be used to build and maintain complex schemas where the reusable parts are defined in separate schemas.
186+
187+
The following is the main schema and a `customer` sub-schema that defines the `contacts` and `address` properties.
188+
189+
```js
190+
import { Cabidela } from "@cloudflare/cabidela";
191+
192+
const schema = {
193+
$id: "http://example.com/schemas/main",
194+
type: "object",
195+
properties: {
196+
name: { type: "string" },
197+
contacts: { $ref: "customer#/contacts" },
198+
address: { $ref: "customer#/address" },
199+
balance: { $ref: "$defs#/balance" },
200+
},
201+
required: ["name", "contacts", "address"],
202+
"$defs": {
203+
"balance": {
204+
type: "object",
205+
prope properties: {
206+
currency: { type: "string" },
207+
amount: { type: "number" },
208+
},
209+
}
210+
}
211+
};
212+
213+
const contactSchema = {
214+
$id: "http://example.com/schemas/customer",
215+
contacts: {
216+
type: "object",
217+
properties: {
218+
email: { type: "string" },
219+
phone: { type: "string" },
220+
},
221+
required: ["email", "phone"],
222+
},
223+
address: {
224+
type: "object",
225+
properties: {
226+
street: { type: "string" },
227+
city: { type: "string" },
228+
zip: { type: "string" },
229+
country: { type: "string" },
230+
},
231+
required: ["street", "city", "zip", "country"],
232+
},
233+
};
234+
235+
const cabidela = new Cabidela(schema, { subSchemas: [contactSchema] });
236+
237+
cabidela.validate({
238+
name: "John",
239+
contacts: {
240+
241+
phone: "+123456789",
242+
},
243+
address: {
244+
street: "123 Main St",
245+
city: "San Francisco",
246+
zip: "94105",
247+
country: "USA",
248+
},
249+
});
250+
```
251+
183252
## Custom errors
184253

185-
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.
254+
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.
186255

187256
```js
188257
const schema = {
@@ -204,7 +273,7 @@ const payload = {
204273

205274
cabidela.validate(payload);
206275
// throws "Error: prompt required"
207-
````
276+
```
208277

209278
## Tests
210279

@@ -262,7 +331,7 @@ Here are some results:
262331
59.75x faster than Ajv
263332

264333
Cabidela - benchmarks/80-big-ops.bench.js > allOf, two properties
265-
1701.95x faster than Ajv
334+
1701.95x faster than Ajv
266335

267336
Cabidela - benchmarks/80-big-ops.bench.js > allOf, two objects
268337
1307.04x faster than Ajv
@@ -285,10 +354,7 @@ npm run benchmark
285354
Cabidela supports most of JSON Schema specification, and should be useful for many applications, but it's not complete. **Currently** we do not support:
286355

287356
- Multiple (array of) types `{ "type": ["number", "string"] }`
288-
- Regular expressions
289357
- Pattern properties
290-
- `not`
291358
- `dependentRequired`, `dependentSchemas`, `If-Then-Else`
292-
- `$ref`, `$defs` and `$id`
293359

294360
yet.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cloudflare/cabidela",
3-
"version": "0.0.19",
3+
"version": "0.1.1",
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",

src/helpers.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,34 @@ export const includesAll = (arr: Array<any>, values: Array<any>) => {
1313
return values.every((v) => arr.includes(v));
1414
};
1515

16+
// https://json-schema.org/understanding-json-schema/structuring#dollarref
17+
export const parse$ref = (ref: string) => {
18+
const parts = ref.split("#");
19+
const $id = parts[0];
20+
const $path = parts[1].split("/").filter((part: string) => part !== "");
21+
return { $id, $path };
22+
};
23+
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`);
38+
}
39+
}
40+
}
41+
});
42+
};
43+
1644
/* Resolves a path in an object
1745
1846
obj = {
@@ -48,16 +76,16 @@ export const resolvePayload = (path: Array<string | number>, obj: any): resolved
4876
return { metadata: getMetaData(resolvedObject), resolvedObject };
4977
};
5078

79+
// JSON Pointer notation https://datatracker.ietf.org/doc/html/rfc6901
5180
export const pathToString = (path: Array<string | number>) => {
52-
return path.length == 0 ? `.` : path.map((item) => (typeof item === "number" ? `[${item}]` : `.${item}`)).join("");
81+
return path.length == 0 ? `/` : path.map((item) => `/${item}`).join("");
5382
};
5483

5584
// https://json-schema.org/understanding-json-schema/reference/type
56-
5785
export const getMetaData = (value: any): metaData => {
5886
let size = 0;
59-
let types:any = new Set([]);
60-
let properties:any = [];
87+
let types: any = new Set([]);
88+
let properties: any = [];
6189
if (value === null) {
6290
types.add("null");
6391
} else if (typeof value == "string") {

src/index.ts

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

33
export type CabidelaOptions = {
44
applyDefaults?: boolean;
55
errorMessages?: boolean;
66
fullErrors?: boolean;
7+
subSchemas?: Array<any>;
78
};
89

910
export type SchemaNavigation = {
@@ -19,25 +20,57 @@ export type SchemaNavigation = {
1920
};
2021

2122
export class Cabidela {
22-
public schema: any;
23-
public options: CabidelaOptions;
23+
private schema: any;
24+
private options: CabidelaOptions;
25+
private definitions: any = {};
2426

2527
constructor(schema: any, options?: CabidelaOptions) {
2628
this.schema = schema;
2729
this.options = {
2830
fullErrors: true,
31+
subSchemas: [],
2932
applyDefaults: false,
3033
errorMessages: false,
3134
...(options || {}),
3235
};
36+
if (this.schema.hasOwnProperty("$defs")) {
37+
this.definitions["$defs"] = this.schema["$defs"];
38+
delete this.schema["$defs"];
39+
}
40+
if ((this.options.subSchemas as []).length > 0) {
41+
for (const subSchema of this.options.subSchemas as []) {
42+
this.addSchema(subSchema, false);
43+
}
44+
traverseSchema(this.definitions, this.schema);
45+
}
3346
}
3447

3548
setSchema(schema: any) {
3649
this.schema = schema;
3750
}
3851

52+
addSchema(subSchema: any, combine: boolean = true) {
53+
if (subSchema.hasOwnProperty("$id")) {
54+
const url = URL.parse(subSchema["$id"]);
55+
if (url) {
56+
this.definitions[url.pathname.split("/").slice(-1)[0]] = subSchema;
57+
} else {
58+
throw new Error(
59+
"subSchemas need a valid retrieval URI $id https://json-schema.org/understanding-json-schema/structuring#retrieval-uri",
60+
);
61+
}
62+
} else {
63+
throw new Error("subSchemas need $id https://json-schema.org/understanding-json-schema/structuring#id");
64+
}
65+
if (combine == true) traverseSchema(this.definitions, this.schema);
66+
}
67+
68+
getSchema() {
69+
return this.schema;
70+
}
71+
3972
setOptions(options: CabidelaOptions) {
40-
this.options = options;
73+
this.options = { ...this.options, ...options };
4174
}
4275

4376
throw(message: string, needle: SchemaNavigation) {
@@ -73,7 +106,7 @@ export class Cabidela {
73106
for (let property of unevaluatedProperties) {
74107
if (
75108
this.parseSubSchema({
76-
path: [property.split(".").slice(-1)[0]],
109+
path: [property.split("/").slice(-1)[0]],
77110
schema: contextAdditionalProperties,
78111
payload: resolvedObject,
79112
evaluatedProperties: new Set(),
@@ -152,7 +185,7 @@ export class Cabidela {
152185
needle.evaluatedProperties.union(localEvaluatedProperties),
153186
).size > 0
154187
) {
155-
this.throw(`required properties at '${pathToString(needle.path)}' is '${needle.schema.required}'`, needle);
188+
this.throw(`required properties at '${pathToString(needle.path)}' are '${needle.schema.required}'`, needle);
156189
}
157190
}
158191
return matchCount ? true : false;
@@ -191,6 +224,22 @@ export class Cabidela {
191224
this.throw(`No schema for path '${pathToString(needle.path)}'`, needle);
192225
}
193226

227+
// https://json-schema.org/understanding-json-schema/reference/combining#not
228+
if (needle.schema.hasOwnProperty("not")) {
229+
let pass = false;
230+
try {
231+
this.parseSubSchema({
232+
...needle,
233+
schema: needle.schema.not,
234+
});
235+
} catch (e: any) {
236+
pass = true;
237+
}
238+
if (pass == false) {
239+
this.throw(`not at '${pathToString(needle.path)}' not met`, needle);
240+
}
241+
}
242+
194243
// To validate against oneOf, the given data must be valid against exactly one of the given subschemas.
195244
if (needle.schema.hasOwnProperty("oneOf")) {
196245
const rounds = this.parseList(needle.schema.oneOf, needle, (r: number) => r !== 1);
@@ -322,6 +371,14 @@ export class Cabidela {
322371
break;
323372
}
324373
}
374+
if (needle.schema.hasOwnProperty("pattern")) {
375+
let passes = false;
376+
try {
377+
if (new RegExp(needle.schema.pattern).test(resolvedObject)) passes = true;
378+
} catch (e) {}
379+
if (!passes) this.throw(`'${pathToString(needle.path)}' failed test ${needle.schema.pattern} patttern`, needle);
380+
}
381+
325382
if (needle.carryProperties) {
326383
needle.evaluatedProperties.add(pathToString(needle.path));
327384
}

tests/00-basic.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test, describe, test } from "vitest";
1+
import { expect, test, describe } from "vitest";
22
import { Cabidela } from "../src";
33
import { getMetaData } from "../src/helpers";
44

0 commit comments

Comments
 (0)