Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

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

## [0.3.0] - 2026-09-08

### Added

- Added opt-in `$patch` support for all six JSON Patch operations, including array edits and schema references

### Changed

- Schema configuration updates prepare references and extensions before committing, preserving the root object's identity
- Schema traversal preserves literal JSON data and supports fragmentless references
- Composition validation counts successful branches and reports nested failures consistently
- Corrected the `maxProperties` error message

## [0.2.4] - 2025-03-24

### Changed
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Cabidela takes a JSON-Schema and optional configuration flags:
- `errorMessages`: boolean - If true, the validator will use custom `errorMessage` messages from the schema. Default is false.
- `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.
- `useMerge`: boolean - Set to true if you want to use the `$merge` keyword. Default is false. See below for more information.
- `usePatch`: boolean - Set to true if you want to use the `$patch` keyword. Default is false. See below for more information.
- `subSchemas`: any[] - An optional array of sub-schemas that can be used with `$id` and `$ref`. See below for more information.

Returns a validation object.
Expand All @@ -80,6 +81,14 @@ You can change the schema at any time by calling `cabidela.setSchema(schema: any

You can change the options at any time by calling `cabidela.setOptions(options: CabidelaOptions)`.

`setSchema`, `setOptions`, and `addSchema` prepare references and enabled extensions before committing an update. If preparation fails, the active schema and configuration remain unchanged. Cabidela preserves the root schema object's identity, so `getSchema()` returns the supplied object with its resolved fields. Preparation copies changed branches rather than cloning an unchanged schema.

Use plain JSON data objects for schemas. A frozen or sealed root is usable when preparation leaves it unchanged. If preparation needs to add, remove, or replace a root field, the root must permit those changes; Cabidela checks them before writing and throws if they are not allowed. Accessor properties and proxies are outside this JSON data contract.

Register another schema with `cabidela.addSchema(subSchema)`; its `$id` must be a valid retrieval URI. References resolve immediately by default. `cabidela.addSchema(subSchema, false)` registers it for the next preparation without resolving the current schema yet. For several interdependent schemas, pass them together in `subSchemas` when constructing the validator.

Preparation consumes `$ref`, `$merge`, and `$patch` wrappers. Changing an option later does not reconstruct an earlier source schema or undo an already applied patch; use `setSchema` to install a new source.

### Validate payload

Call `cabidela.validate(payload: any)` to validate your payload.
Expand Down Expand Up @@ -299,6 +308,51 @@ new Cabidela(schema, { useMerge: true });

You can combine `$merge` with `$id` and `$ref` keywords, which get resolved first, for even more flexibility.

## $patch

Enable `usePatch` to transform a schema with [JSON Patch (RFC 6902)](https://datatracker.ietf.org/doc/html/rfc6902). For example, narrow the allowed reasoning efforts while reusing a shared input schema:

```js
const schema = {
$defs: {
input: {
type: "object",
properties: {
reasoning_effort: {
type: "string",
enum: ["low", "medium", "high"],
},
},
required: ["reasoning_effort"],
},
},
$patch: {
source: { $ref: "$defs#/input" },
with: [
{
op: "replace",
path: "/properties/reasoning_effort/enum",
value: ["low", "high"],
},
],
},
};

const cabidela = new Cabidela(schema, { usePatch: true });
cabidela.validate({ reasoning_effort: "high" }); // true
cabidela.validate({ reasoning_effort: "medium" }); // throws
```

`with` is an ordered array of operations. All six operations are supported: `add`, `remove`, `replace`, `move`, `copy`, and `test`. A failed operation rejects the entire schema update. `null` is an ordinary JSON value; use `remove` to delete a field or array element.

Paths use [JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901): escape `/` as `~1` and `~` as `~0`. An empty path replaces or addresses the whole source; `-` appends to an array for `add`. For example, `{ op: "remove", path: "/properties/reasoning_effort/enum/1" }` removes the second enum entry.

The final result of each `$patch` must be an object schema. Boolean schemas, arrays, scalars, and a removed root cannot be installed as the result. This is a restriction of Cabidela's schema wrapper; JSON Patch itself also supports those document types.

References and enabled extensions in `source` resolve before the operations run. Operation values remain literal JSON during patching. Afterward, references or extensions introduced at schema locations are resolved; data inside `default`, `const`, enum members, examples, and custom annotations is preserved. References can use `$defs#/name` for local definitions or `input` / `input#/path` for a registered schema whose retrieval URI ends in `input`.

`$patch` can be nested in subschemas and combined with `$merge` when both flags are enabled. Existing wrappers around an entire `properties` map also work. A property named `$patch`, `$merge`, or `$ref` remains a property when its value is a schema; complete `source`/`with` wrappers at map level retain their extension meaning.

## 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.
Expand Down
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.2.4",
"version": "0.3.0",
"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
68 changes: 0 additions & 68 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { CabidelaOptions } from ".";

export type metaData = {
types: Set<string>;
size: number;
Expand All @@ -15,72 +13,6 @@ 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("#");
return {
$id: parts[0],
$path: parts[1].split("/").filter((part: string) => part != ""),
};
};

function deepMerge(target: any, source: any) {
const result = Array(target) && Array.isArray(source) ? target.concat(source) : { ...target, ...source };
for (const key of Object.keys(result)) {
result[key] =
typeof target[key] == "object" && typeof source[key] == "object"
? deepMerge(target[key], source[key])
: structuredClone(result[key]);
}
return result;
}

export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: any) => {
const ts = (obj: any, cb?: any) => {
let hits: number;
do {
hits = 0;
for (const key of Object.keys(obj)) {
if (typeof obj[key] == "object") {
ts(obj[key], (value: any) => {
obj[key] = value;
hits++;
});
if (options.useMerge && key == "$merge") {
const merge = deepMerge(obj[key].source, obj[key].with);
if (cb) {
cb(merge);
} else {
// root level
hits++;
Object.assign(obj, merge);
delete obj[key];
}
}
} else {
if (key == "$ref") {
const { $id, $path } = parse$ref(obj[key]);
const { resolvedObject } = resolvePayload($path, definitions[$id]);
if (resolvedObject) {
if (cb) {
cb(resolvedObject);
} else {
// root level
hits++;
Object.assign(obj, resolvedObject);
delete obj[key];
}
} else {
throw new Error(`Could not resolve '${obj[key]}' $ref`);
}
}
}
}
} while (hits > 0);
};
ts(obj);
};

/* Resolves a path in an object

obj = {
Expand Down
Loading