Skip to content

Commit

Permalink
Merge pull request #5 from cloudflare/refs
Browse files Browse the repository at this point in the history
Adds $ref and $id support
  • Loading branch information
celso authored Mar 2, 2025
2 parents feb2224 + 1aebbbd commit 67eea29
Show file tree
Hide file tree
Showing 11 changed files with 380 additions and 24 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/npm-publish-github-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22.10.0
registry-url: https://npm.pkg.github.com/
registry-url: https://registry.npmjs.org/
- run: npm install
- run: npm run build
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

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

## [0.1.1] - 2025-02-26

### Added

- Added support for $id, $ref and $defs - https://json-schema.org/understanding-json-schema/structuring
- Added support for not - https://json-schema.org/understanding-json-schema/reference/combining#not

## [0.0.19] - 2025-02-26

### Added
Expand Down
78 changes: 72 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,78 @@ example, using this schema:
- The payload `{ moon: 10}` will be modified to `{ sun: 9000, moon: 10 }`.
- The payload `{ saturn: 10}` will throw an error because no condition is met.

### $id, $ref, $defs

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.

The following is the main schema and a `customer` sub-schema that defines the `contacts` and `address` properties.

```js
import { Cabidela } from "@cloudflare/cabidela";

const schema = {
$id: "http://example.com/schemas/main",
type: "object",
properties: {
name: { type: "string" },
contacts: { $ref: "customer#/contacts" },
address: { $ref: "customer#/address" },
balance: { $ref: "$defs#/balance" },
},
required: ["name", "contacts", "address"],
"$defs": {
"balance": {
type: "object",
prope properties: {
currency: { type: "string" },
amount: { type: "number" },
},
}
}
};

const contactSchema = {
$id: "http://example.com/schemas/customer",
contacts: {
type: "object",
properties: {
email: { type: "string" },
phone: { type: "string" },
},
required: ["email", "phone"],
},
address: {
type: "object",
properties: {
street: { type: "string" },
city: { type: "string" },
zip: { type: "string" },
country: { type: "string" },
},
required: ["street", "city", "zip", "country"],
},
};

const cabidela = new Cabidela(schema, { subSchemas: [contactSchema] });

cabidela.validate({
name: "John",
contacts: {
email: "[email protected]",
phone: "+123456789",
},
address: {
street: "123 Main St",
city: "San Francisco",
zip: "94105",
country: "USA",
},
});
```

## Custom errors

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

```js
const schema = {
Expand All @@ -204,7 +273,7 @@ const payload = {

cabidela.validate(payload);
// throws "Error: prompt required"
````
```

## Tests

Expand Down Expand Up @@ -262,7 +331,7 @@ Here are some results:
59.75x faster than Ajv

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

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

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

yet.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cloudflare/cabidela",
"version": "0.0.19",
"version": "0.1.1",
"description": "Cabidela is a small, fast, eval-less, Cloudflare Workers compatible, dynamic JSON Schema validator",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down
36 changes: 32 additions & 4 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,34 @@ export const includesAll = (arr: Array<any>, values: Array<any>) => {
return values.every((v) => arr.includes(v));
};

// https://json-schema.org/understanding-json-schema/structuring#dollarref
export const parse$ref = (ref: string) => {
const parts = ref.split("#");
const $id = parts[0];
const $path = parts[1].split("/").filter((part: string) => part !== "");
return { $id, $path };
};

export const traverseSchema = (definitions: any, obj: any, cb: any = () => {}) => {
Object.keys(obj).forEach((key) => {
if (obj[key] !== null && typeof obj[key] === "object") {
traverseSchema(definitions, obj[key], (value: any) => {
obj[key] = value;
});
} else {
if (key === "$ref") {
const { $id, $path } = parse$ref(obj[key]);
const { resolvedObject } = resolvePayload($path, definitions[$id]);
if (resolvedObject) {
cb(resolvedObject);
} else {
throw new Error(`Could not resolve '${obj[key]}' $ref`);
}
}
}
});
};

/* Resolves a path in an object
obj = {
Expand Down Expand Up @@ -48,16 +76,16 @@ export const resolvePayload = (path: Array<string | number>, obj: any): resolved
return { metadata: getMetaData(resolvedObject), resolvedObject };
};

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

// https://json-schema.org/understanding-json-schema/reference/type

export const getMetaData = (value: any): metaData => {
let size = 0;
let types:any = new Set([]);
let properties:any = [];
let types: any = new Set([]);
let properties: any = [];
if (value === null) {
types.add("null");
} else if (typeof value == "string") {
Expand Down
69 changes: 63 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { resolvePayload, pathToString } from "./helpers";
import { resolvePayload, pathToString, traverseSchema } from "./helpers";

export type CabidelaOptions = {
applyDefaults?: boolean;
errorMessages?: boolean;
fullErrors?: boolean;
subSchemas?: Array<any>;
};

export type SchemaNavigation = {
Expand All @@ -19,25 +20,57 @@ export type SchemaNavigation = {
};

export class Cabidela {
public schema: any;
public options: CabidelaOptions;
private schema: any;
private options: CabidelaOptions;
private definitions: any = {};

constructor(schema: any, options?: CabidelaOptions) {
this.schema = schema;
this.options = {
fullErrors: true,
subSchemas: [],
applyDefaults: false,
errorMessages: false,
...(options || {}),
};
if (this.schema.hasOwnProperty("$defs")) {
this.definitions["$defs"] = this.schema["$defs"];
delete this.schema["$defs"];
}
if ((this.options.subSchemas as []).length > 0) {
for (const subSchema of this.options.subSchemas as []) {
this.addSchema(subSchema, false);
}
traverseSchema(this.definitions, this.schema);
}
}

setSchema(schema: any) {
this.schema = schema;
}

addSchema(subSchema: any, combine: boolean = true) {
if (subSchema.hasOwnProperty("$id")) {
const url = URL.parse(subSchema["$id"]);
if (url) {
this.definitions[url.pathname.split("/").slice(-1)[0]] = subSchema;
} else {
throw new Error(
"subSchemas need a valid retrieval URI $id https://json-schema.org/understanding-json-schema/structuring#retrieval-uri",
);
}
} else {
throw new Error("subSchemas need $id https://json-schema.org/understanding-json-schema/structuring#id");
}
if (combine == true) traverseSchema(this.definitions, this.schema);
}

getSchema() {
return this.schema;
}

setOptions(options: CabidelaOptions) {
this.options = options;
this.options = { ...this.options, ...options };
}

throw(message: string, needle: SchemaNavigation) {
Expand Down Expand Up @@ -73,7 +106,7 @@ export class Cabidela {
for (let property of unevaluatedProperties) {
if (
this.parseSubSchema({
path: [property.split(".").slice(-1)[0]],
path: [property.split("/").slice(-1)[0]],
schema: contextAdditionalProperties,
payload: resolvedObject,
evaluatedProperties: new Set(),
Expand Down Expand Up @@ -152,7 +185,7 @@ export class Cabidela {
needle.evaluatedProperties.union(localEvaluatedProperties),
).size > 0
) {
this.throw(`required properties at '${pathToString(needle.path)}' is '${needle.schema.required}'`, needle);
this.throw(`required properties at '${pathToString(needle.path)}' are '${needle.schema.required}'`, needle);
}
}
return matchCount ? true : false;
Expand Down Expand Up @@ -191,6 +224,22 @@ export class Cabidela {
this.throw(`No schema for path '${pathToString(needle.path)}'`, needle);
}

// https://json-schema.org/understanding-json-schema/reference/combining#not
if (needle.schema.hasOwnProperty("not")) {
let pass = false;
try {
this.parseSubSchema({
...needle,
schema: needle.schema.not,
});
} catch (e: any) {
pass = true;
}
if (pass == false) {
this.throw(`not at '${pathToString(needle.path)}' not met`, needle);
}
}

// To validate against oneOf, the given data must be valid against exactly one of the given subschemas.
if (needle.schema.hasOwnProperty("oneOf")) {
const rounds = this.parseList(needle.schema.oneOf, needle, (r: number) => r !== 1);
Expand Down Expand Up @@ -322,6 +371,14 @@ export class Cabidela {
break;
}
}
if (needle.schema.hasOwnProperty("pattern")) {
let passes = false;
try {
if (new RegExp(needle.schema.pattern).test(resolvedObject)) passes = true;
} catch (e) {}
if (!passes) this.throw(`'${pathToString(needle.path)}' failed test ${needle.schema.pattern} patttern`, needle);
}

if (needle.carryProperties) {
needle.evaluatedProperties.add(pathToString(needle.path));
}
Expand Down
2 changes: 1 addition & 1 deletion tests/00-basic.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test, describe, test } from "vitest";
import { expect, test, describe } from "vitest";
import { Cabidela } from "../src";
import { getMetaData } from "../src/helpers";

Expand Down
Loading

0 comments on commit 67eea29

Please sign in to comment.