feat(schema): add pi-schema, JSON Schema builders and validation without eval

@earendil-works/pi-schema compiles JSON Schema (drafts 2020-12 and 7) once into
plain closures: no eval or new Function, so it runs under a strict CSP. It
passes 2,222 of the 2,223 core tests of the official JSON-Schema-Test-Suite,
checks Chord-style argument schemas within 1.2x of TypeBox's JIT, and keeps
3,200 compiled schemas in 7.9 MB (TypeBox: 54.7 MB).

- compile(schema, options) returns a Validator: check() is the boolean check
  (a type guard for builder schemas), errors() reports each failure with a
  JSON Pointer path, missing and unknown fields at the field.
- Builders (str, int, obj, opt, union, ...) return plain JSON Schema typed with
  the values it accepts (Static<typeof schema>).
- Formats are an optional entry point (./formats), adapted from TypeBox (MIT).
- Tests run the vendored suite (drafts 2020-12 and 7, with optional and format
  tests); npm run bench compares conformance, speed, setup cost, memory and
  bundle size with TypeBox, @cfworker/json-schema and omptype.
This commit is contained in:
Mario Zechner 2026-10-31 21:39:27 +00:00
parent 7ef8680abd
commit fab2c5353e
255 changed files with 37963 additions and 2 deletions

View file

@ -0,0 +1,9 @@
# Changelog
## [Unreleased]
### Added
- Initial release: `compile(schema, options)` validates JSON Schema drafts 2020-12 and 7 without `eval` or `new Function`, compiling a schema once into plain closures. `Validator.check` is the boolean check (a type guard for schemas built with the builders), `Validator.errors` reports each failure with a JSON Pointer path. Format checks are in the optional `@earendil-works/pi-schema/formats` entry point.
- Schema builders (`str`, `num`, `int`, `bool`, `nul`, `unknown`, `lit`, `oneOf`, `obj`, `opt`, `arr`, `tuple`, `map`, `union`, `nullable`, `bytes`) that return plain JSON Schema typed with the values it accepts (`Static<typeof schema>`).
- `npm run bench` compares conformance, speed, setup cost, memory and bundle size with TypeBox, @cfworker/json-schema and omptype.

View file

@ -0,0 +1,23 @@
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

178
packages/schema/README.md Normal file
View file

@ -0,0 +1,178 @@
# @earendil-works/pi-schema
JSON Schema builders and validation without `eval`. A schema compiles once into plain closures, so it checks values
about as fast as validators that generate code, also under a strict Content Security Policy, and needs no
dependencies.
- Drafts 2020-12 and 7. Passes 2,222 of the 2,223 core tests of the official
[JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite).
- Builders return plain JSON Schema typed with the values it accepts; a compiled check narrows to that type.
- Errors as JSON Pointer paths. A missing or unknown field is reported at the field.
- Never modifies a schema; frozen schemas work.
- 8.5 KB minified and gzipped. Format checks are an optional import.
## Installation
```bash
npm install @earendil-works/pi-schema
```
## Usage
```typescript
import { compile, int, obj, oneOf, opt, type Static, str } from "@earendil-works/pi-schema";
const Read = obj({
path: str({ description: "Path to the file", minLength: 1 }),
offset: opt(int({ minimum: 1 })),
mode: oneOf(["text", "binary"]),
});
type Read = Static<typeof Read>; // { path: string; mode: "text" | "binary"; offset?: number }
const read = compile(Read);
if (read.check(value)) value.path; // value: Read
read.errors({ offset: 0, extra: true });
// [
// { path: "/path", keyword: "required", message: "missing" },
// { path: "/mode", keyword: "required", message: "missing" },
// { path: "/offset", keyword: "minimum", message: "expected >= 1" },
// { path: "/extra", keyword: "additionalProperties", message: "unknown field" },
// ]
```
`Read` is plain JSON Schema (`{ type: "object", properties: {...}, required: ["path", "mode"], additionalProperties:
false }`), ready for a tool definition or `JSON.stringify`. `compile` takes any JSON Schema, built with the builders
or not.
`check(value)` is the fast path: a boolean, no allocations for most schemas, and a plain function you can pass
around. `errors(value)` explains a failure and returns `[]` for a valid value. It is built on the first failure and
kept, so a schema whose values always pass never pays for it.
### Builders
| builder | schema | type |
|---|---|---|
| `str({ minLength, maxLength, pattern, format })` | `{ type: "string", ... }` | `string` |
| `num({ minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf })`, `int(...)` | `{ type: "number" }`, `{ type: "integer" }` | `number` |
| `bool()`, `nul()`, `unknown()` | `{ type: "boolean" }`, `{ type: "null" }`, `{}` | `boolean`, `null`, `unknown` |
| `lit(value)` | `{ const: value }` | the literal |
| `oneOf(["a", "b"])` | `{ enum: [...] }` | `"a" \| "b"` |
| `obj({ a: str(), b: opt(num()) }, { open })` | closed object; `open: true` allows other properties | `{ a: string; b?: number }` |
| `arr(items, { minItems, maxItems, uniqueItems })` | `{ type: "array", items }` | `T[]` |
| `tuple([str(), num()])` | `prefixItems`, no more items | `[string, number]` |
| `map(value)` | `{ type: "object", additionalProperties: value }` | `Record<string, T>` |
| `union([...])` | `{ anyOf: [...] }` | the union |
| `nullable(schema)` | `{ anyOf: [schema, { type: "null" }] }` | `T \| null` |
| `bytes({ minLength, maxLength })` | `{ type: "bytes", ... }` | `Uint8Array` |
Every builder also takes `title`, `description`, `examples`, `deprecated` and `default`. A `default` is an annotation;
validation never inserts it.
A union of objects tagged by a `lit()` property checks and reports by the tag: only the branch the tag selects runs.
```typescript
const Block = union([obj({ kind: lit("text"), text: str() }), obj({ kind: lit("image"), width: int({ minimum: 0 }) })]);
compile(Block).errors({ kind: "video" });
// [{ path: "/kind", keyword: "anyOf", message: 'expected one of ["text","image"]' }]
```
### Options
```typescript
import { formats } from "@earendil-works/pi-schema/formats";
const validator = compile(schema, {
draft: "7", // "2020-12" (default) or "7"
formats, // check `format`; without it `format` is an annotation, as 2020-12 specifies
schemas: { "https://example.com/point.json": point }, // documents for $refs to other documents, by URI
});
```
`formats` has every format 2020-12 defines (`date-time`, `email`, `uri`, `uuid`, `ipv6`, ...). Pass your own map to
check fewer formats, or custom ones. A `$ref` that names nothing throws when the schema compiles.
### TypeBox schemas
TypeBox 1.x schemas are JSON Schema, so they compile as they are:
```typescript
import Type from "typebox";
const user = compile(Type.Object({ name: Type.String(), tags: Type.Array(Type.String()) }));
```
TypeBox's JavaScript-only types (`BigInt`, `Undefined`, `Function`, ...), refinements and codecs are not JSON Schema
and are not supported.
### Values
- A property whose value is `undefined` counts as absent, as in TypeBox.
- `{ type: "bytes", minLength, maxLength }` matches a `Uint8Array` of that length, an extension for values that never
pass through JSON.
## Supported keywords
- Every validation keyword of drafts 2020-12 and 7, including draft 7's `dependencies` and `items` arrays with
`additionalItems`.
- `$id`, `$anchor`, `$ref` (local, by anchor, relative and remote), `$dynamicRef` and `$dynamicAnchor`.
- `unevaluatedProperties` and `unevaluatedItems`.
- `pattern` and `patternProperties` are ECMAScript regular expressions with the `u` flag.
- Not supported: meta-schemas that switch vocabularies on or off (`$vocabulary`), `contentMediaType` and
`contentEncoding` assertions (optional in the spec), and draft 2019-09's `$recursiveRef`.
## Benchmarks
Node 24.4.1 on one core of an i9-13900, against TypeBox 1.3.27, @cfworker/json-schema 4.1.1 and @oh-my-pi/omptype
18.13.3 (`fromJsonSchema`).
| test suite (tests passed) | pi-schema | TypeBox | cfworker | omptype |
|---|---:|---:|---:|---:|
| draft 2020-12 core (1,298) | 1,297 | 1,276 | 1,249 | 863 |
| draft 7 core (925) | 925 | 924 | 918 | 659 |
| draft 2020-12 format (895), with `formats` | 895 | 895 | 740 | 412 |
| check, valid value | pi-schema | TypeBox | cfworker | omptype |
|---|---:|---:|---:|---:|
| nested object with tagged unions | 535 ns | 464 ns | 43 µs | 713 ns |
| union of 5 messages | 96 ns | 94 ns | 10 µs | 94 ns |
| tool schema (read) | 34 ns | 9 ns | 2.0 µs | 8 ns |
| MCP tool schema with `$ref` | 207 ns | 88 ns | 13 µs | 1.7 µs |
| recursive `$ref` schema | 1.1 µs | 1.2 µs | 41 µs | 7.4 µs |
| same, without eval (strict CSP) | pi-schema | TypeBox | cfworker | omptype |
|---|---:|---:|---:|---:|
| nested object with tagged unions | 527 ns | 42 µs | 44 µs | 2.7 µs |
| MCP tool schema with `$ref` | 209 ns | 38 µs | 13 µs | 2.1 µs |
| recursive `$ref` schema | 1.1 µs | 134 µs | 43 µs | 8.9 µs |
| cost | pi-schema | TypeBox | cfworker | omptype |
|---|---:|---:|---:|---:|
| compile a new schema | 43 µs | 210 µs | 23 µs | 19 µs + 114 µs on its first check |
| memory, 3,200 distinct schemas | 7.9 MB | 54.7 MB | 7.6 MB | 20.0 MB |
| bundle, minified + gzip | 8.5 KB | 35.2 KB | 5.8 KB | 38.8 KB |
Reproduce with `npm run bench` in `packages/schema` (about 5 minutes). It runs every library through its fastest
boolean path and keeps the object each returns. The scripts are in `test/bench/`.
- **conformance:** every library on the vendored test suite, with the remote documents and meta-schemas the tests
reference.
- **speed:** realistic schemas, in ns per check after warm-up; the median of 15 rounds, with the libraries'
order rotated in each round.
- **speed without eval:** the same, run with `--disallow-code-generation-from-strings`.
- **setup:** compiling 1,000 schemas a library has not seen, and their first checks.
- **memory:** 3,200 distinct tool-like schemas, each library in its own process.
- **size:** esbuild bundles of what a validator needs, minified and gzipped.
Where code generation is unavailable, TypeBox falls back to its interpreter, and omptype throws `EvalError` once per
schema, then validates through its interpreter.
Flat objects of simple types check in 15–90 ns, against 8–15 ns for TypeBox. Without code generation, reading a
property whose name comes from a list costs a few nanoseconds.
## Development
`npm test` runs the official test suite (drafts 2020-12 and 7, including the optional and format tests, vendored in
`test/fixtures/`) and the package's own tests.
The format checks are adapted from TypeBox 1.3.27 (MIT); its license is in `LICENSES/`.

View file

@ -0,0 +1,56 @@
{
"name": "@earendil-works/pi-schema",
"version": "1.4.0",
"description": "JSON Schema validation without eval: drafts 2020-12 and 7, compiled into plain closures",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./formats": {
"source": "./src/formats/index.ts",
"types": "./dist/formats/index.d.ts",
"import": "./dist/formats/index.js"
}
},
"sideEffects": false,
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"LICENSES"
],
"scripts": {
"clean": "node ../../scripts/package-files.mjs clean dist",
"build": "tsc -p tsconfig.build.json",
"test": "vitest --run",
"bench": "node --expose-gc test/bench/all.ts",
"prepublishOnly": "npm run clean && npm run build"
},
"keywords": [
"json-schema",
"validation",
"validator",
"csp"
],
"author": "Earendil Works",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/schema"
},
"engines": {
"node": ">=22.19.0"
},
"devDependencies": {
"@cfworker/json-schema": "4.1.1",
"@oh-my-pi/omptype": "18.13.3",
"typebox": "1.3.27",
"vitest": "4.1.12"
}
}

View file

@ -0,0 +1,208 @@
// Annotation tracking for `unevaluatedProperties` and `unevaluatedItems`: annotating checks record which
// properties and items a schema evaluated. Only schemas that use those keywords compile into these closures.
import { isObject } from "./checks.ts";
import type { Check } from "./types.ts";
/** What a schema evaluated: property names (or all of them), the evaluated item prefix, items matched by `contains`. */
export interface Annotations {
props: Set<string> | null;
allProps: boolean;
items: number;
allItems: boolean;
indexes: Set<number> | null;
}
/** A check that records what it evaluated into `a` when it passes. */
export type AnnotatingCheck = (value: unknown, a: Annotations) => boolean;
/** Empty annotations. */
export function newAnnotations(): Annotations {
return { props: null, allProps: false, items: 0, allItems: false, indexes: null };
}
function merge(a: Annotations, b: Annotations): void {
if (b.props !== null) for (const k of b.props) addProp(a, k);
a.allProps ||= b.allProps;
if (b.items > a.items) a.items = b.items;
a.allItems ||= b.allItems;
if (b.indexes !== null) for (const i of b.indexes) addIndex(a, i);
}
function addProp(a: Annotations, key: string): void {
if (a.props === null) a.props = new Set();
a.props.add(key);
}
function addIndex(a: Annotations, index: number): void {
if (a.indexes === null) a.indexes = new Set();
a.indexes.add(index);
}
export const ANNOTATING_TRUE: AnnotatingCheck = () => true;
export const ANNOTATING_FALSE: AnnotatingCheck = () => false;
/** The indirection a recursive reference goes through. */
export function indirectAnnotating(slot: { check: AnnotatingCheck | undefined }): AnnotatingCheck {
return (v, a) => (slot.check as AnnotatingCheck)(v, a);
}
/** A plain check in annotating position: it evaluates nothing that unevaluated* sees. */
export function ignoreAnnotations(check: Check): AnnotatingCheck {
return (v) => check(v);
}
/** An annotating check run as a plain check, with fresh annotations. */
export function withFreshAnnotations(check: AnnotatingCheck): Check {
return (v) => check(v, newAnnotations());
}
/** `check` with `uri` pushed onto the dynamic scope while it runs. */
export function scopedAnnotating(scope: string[], uri: string, check: AnnotatingCheck): AnnotatingCheck {
return (v, a) => {
scope.push(uri);
const ok = check(v, a);
scope.pop();
return ok;
};
}
/** unevaluated* sees only the annotations of its own schema, never those of its siblings in the parent. */
export function isolateAnnotations(check: AnnotatingCheck): AnnotatingCheck {
return (v, a) => {
const b = newAnnotations();
if (!check(v, b)) return false;
merge(a, b);
return true;
};
}
/** The non-annotating keywords, then each annotating part in order. */
export function annotateAll(plain: Check, parts: readonly AnnotatingCheck[]): AnnotatingCheck {
return (v, a) => {
if (!plain(v)) return false;
for (let i = 0; i < parts.length; i++) if (!parts[i](v, a)) return false;
return true;
};
}
/** `properties`, `patternProperties` and `additionalProperties`. */
export function annotateObject(
props: readonly (readonly [string, Check])[],
patterns: readonly (readonly [RegExp, Check])[],
additional: Check | undefined,
): AnnotatingCheck {
const declared = new Set(props.map(([k]) => k));
return (v, a) => {
if (!isObject(v)) return true;
const seen: string[] = [];
for (const [k, check] of props) {
if (!Object.hasOwn(v, k)) continue;
if (!check(v[k])) return false;
seen.push(k);
}
for (const k of Object.keys(v)) {
let matched = declared.has(k);
for (const [re, check] of patterns) {
if (re.test(k)) {
if (!check(v[k])) return false;
matched = true;
seen.push(k);
}
}
if (!matched && additional !== undefined) {
if (!additional(v[k])) return false;
seen.push(k);
}
}
for (const k of seen) addProp(a, k);
return true;
};
}
/** `prefixItems`/`items`/`additionalItems` and `contains` with its bounds. */
export function annotateArray(
prefix: readonly Check[],
rest: Check | undefined,
contains: Check | undefined,
minContains: number,
maxContains: number,
): AnnotatingCheck {
return (v, a) => {
if (!Array.isArray(v)) return true;
const m = Math.min(v.length, prefix.length);
for (let i = 0; i < m; i++) if (!prefix[i](v[i])) return false;
if (rest !== undefined) {
for (let i = prefix.length; i < v.length; i++) if (!rest(v[i])) return false;
a.allItems = true;
}
if (m > a.items) a.items = m;
if (contains !== undefined) {
let n = 0;
for (let i = 0; i < v.length; i++) {
if (contains(v[i])) {
n++;
addIndex(a, i);
}
}
if (n < minContains || n > maxContains) return false;
}
return true;
};
}
/** `anyOf` (or `oneOf` when `exactlyOne`): every passing branch contributes its annotations. */
export function annotateAny(branches: readonly AnnotatingCheck[], exactlyOne: boolean): AnnotatingCheck {
return (v, a) => {
let n = 0;
for (const branch of branches) {
const b = newAnnotations();
if (branch(v, b)) {
n++;
merge(a, b);
}
}
return exactlyOne ? n === 1 : n > 0;
};
}
/** `if`, `then` and `else`: a passing `if` contributes its annotations even without `then`. */
export function annotateIf(
cond: AnnotatingCheck,
then: AnnotatingCheck | undefined,
otherwise: AnnotatingCheck | undefined,
): AnnotatingCheck {
return (v, a) => {
const b = newAnnotations();
if (cond(v, b)) {
merge(a, b);
return then === undefined || then(v, a);
}
return otherwise === undefined || otherwise(v, a);
};
}
/** One `dependentSchemas` entry. */
export function annotateDependent(key: string, check: AnnotatingCheck): AnnotatingCheck {
return (v, a) => !isObject(v) || !Object.hasOwn(v, key) || check(v, a);
}
/** `unevaluatedProperties`: every property no other keyword evaluated passes `check`. */
export function unevaluatedProperties(check: Check): AnnotatingCheck {
return (v, a) => {
if (!isObject(v) || a.allProps) return true;
for (const k of Object.keys(v)) if (!(a.props?.has(k) ?? false) && !check(v[k])) return false;
a.allProps = true;
return true;
};
}
/** `unevaluatedItems`: every item no other keyword evaluated passes `check`. */
export function unevaluatedItems(check: Check): AnnotatingCheck {
return (v, a) => {
if (!Array.isArray(v) || a.allItems) return true;
for (let i = a.items; i < v.length; i++) if (!(a.indexes?.has(i) ?? false) && !check(v[i])) return false;
a.allItems = true;
return true;
};
}

View file

@ -0,0 +1,187 @@
// Schema builders: short functions that return plain JSON Schema objects and carry the static type of the values
// they accept, so `Static<typeof schema>` is the TypeScript type a valid value has. The objects are ordinary data:
// they serialize with JSON.stringify and compile with compile() like any other schema.
declare const STATIC: unique symbol;
declare const OPTIONAL_TYPE: unique symbol;
/** A JSON Schema object whose valid values have type `T` (a type-level brand; nothing extra exists at run time). */
export type Schema<T = unknown> = { readonly [keyword: string]: unknown } & { readonly [STATIC]?: T };
/** The type of the values `S` accepts; `unknown` for schemas not built with these functions. */
export type Static<S> = S extends { readonly [STATIC]?: infer T } ? (unknown extends T ? unknown : T) : unknown;
/** Annotations every builder accepts: they describe the schema and never change what it accepts. */
export interface Annotations<T = unknown> {
readonly title?: string;
readonly description?: string;
readonly examples?: readonly T[];
readonly deprecated?: boolean;
/** A default value, an annotation for tools and forms; validation never inserts it. */
readonly default?: T;
}
/** Length bounds of a string, an array or bytes. */
export interface LengthBounds {
readonly minLength?: number;
readonly maxLength?: number;
}
/** A string, with optional length bounds (in code points), pattern and format. */
export function str(
options: Annotations<string> & LengthBounds & { readonly pattern?: string; readonly format?: string } = {},
): Schema<string> {
return { type: "string", ...options };
}
/** Bounds of a number. */
export interface NumberBounds {
readonly minimum?: number;
readonly maximum?: number;
readonly exclusiveMinimum?: number;
readonly exclusiveMaximum?: number;
readonly multipleOf?: number;
}
/** A number within optional bounds. */
export function num(options: Annotations<number> & NumberBounds = {}): Schema<number> {
return { type: "number", ...options };
}
/** An integer within optional bounds. */
export function int(options: Annotations<number> & NumberBounds = {}): Schema<number> {
return { type: "integer", ...options };
}
/** A boolean. */
export function bool(options: Annotations<boolean> = {}): Schema<boolean> {
return { type: "boolean", ...options };
}
/** `null`. */
export function nul(options: Annotations<null> = {}): Schema<null> {
return { type: "null", ...options };
}
/** Any value. */
export function unknown(options: Annotations = {}): Schema<unknown> {
return { ...options };
}
/** Exactly `value` (a string, number, boolean or null). */
export function lit<const V extends string | number | boolean | null>(
value: V,
options: Annotations<V> = {},
): Schema<V> {
return { const: value, ...options };
}
/** One of the given values. */
export function oneOf<const V extends readonly (string | number | boolean | null)[]>(
values: V,
options: Annotations<V[number]> = {},
): Schema<V[number]> {
return { enum: values, ...options };
}
/**
* Any of the given schemas. A union of objects tagged by a `lit()` property (`{ kind: lit("text"), ... }`) is checked
* by the tag: only the branch it selects runs, and its errors are the ones reported.
*/
export function union<const S extends readonly Schema[]>(
schemas: S,
options: Annotations<Static<S[number]>> = {},
): Schema<Static<S[number]>> {
return { anyOf: schemas, ...options };
}
/** `schema` or `null`. */
export function nullable<T>(schema: Schema<T>, options: Annotations<T | null> = {}): Schema<T | null> {
return { anyOf: [schema, { type: "null" }], ...options };
}
/** An array of `items`, with optional length bounds and unique items. */
export function arr<T>(
items: Schema<T>,
options: Annotations<T[]> & {
readonly minItems?: number;
readonly maxItems?: number;
readonly uniqueItems?: boolean;
} = {},
): Schema<T[]> {
return { type: "array", items, ...options };
}
/** A fixed-length array whose items match `items` in order. */
export function tuple<const S extends readonly Schema[]>(
items: S,
options: Annotations<{ -readonly [K in keyof S]: Static<S[K]> }> = {},
): Schema<{ -readonly [K in keyof S]: Static<S[K]> }> {
return { type: "array", prefixItems: items, items: false, minItems: items.length, ...options };
}
/** An object with string keys and values of `value` (a record). */
export function map<T>(value: Schema<T>, options: Annotations<Record<string, T>> = {}): Schema<Record<string, T>> {
return { type: "object", additionalProperties: value, ...options };
}
/** A Uint8Array with optional length bounds (an extension of JSON Schema for values that never pass through JSON). */
export function bytes(options: Annotations<Uint8Array> & LengthBounds = {}): Schema<Uint8Array> {
return { type: "bytes", ...options };
}
/** An optional property of obj(): `{ name: opt(str()) }` may omit `name` or set it to undefined. */
export class Optional<S extends Schema = Schema> {
declare readonly [OPTIONAL_TYPE]?: S;
readonly schema: S;
constructor(schema: S) {
this.schema = schema;
}
}
/** Marks an obj() property as optional. */
export function opt<S extends Schema>(schema: S): Optional<S> {
return new Optional(schema);
}
/** The properties of obj(): a schema or an opt() schema per name. */
export type Properties = { readonly [name: string]: Schema | Optional };
/** The value type of an obj() schema. */
export type StaticObject<P extends Properties> = Simplify<
{ -readonly [K in keyof P as P[K] extends Optional ? never : K]: Static<P[K]> } & {
-readonly [K in keyof P as P[K] extends Optional ? K : never]?: P[K] extends Optional<infer S>
? Static<S>
: never;
}
>;
type Simplify<T> = { [K in keyof T]: T[K] } & {};
/**
* A closed object: the given properties (required unless opt()) and no others. `open: true` allows other
* properties, as tool schemas from other systems often do.
*/
export function obj<const P extends Properties>(
properties: P,
options: Annotations<StaticObject<P>> & { readonly open?: boolean } = {},
): Schema<StaticObject<P>> {
const { open, ...annotations } = options;
const props: Record<string, Schema> = {};
const required: string[] = [];
for (const [name, property] of Object.entries(properties)) {
if (property instanceof Optional) props[name] = property.schema;
else {
props[name] = property;
required.push(name);
}
}
return {
type: "object",
properties: props,
...(required.length > 0 ? { required } : {}),
...(open === true ? {} : { additionalProperties: false }),
...annotations,
};
}

View file

@ -0,0 +1,446 @@
// Check factories: each returns a closure that captures only its arguments, never the compiler, so a compiled
// schema keeps no compile-time state alive. Leaf checks are module constants that object and array loops inline.
import type { Check } from "./types.ts";
export const ALWAYS: Check = () => true;
export const NEVER: Check = () => false;
const IS_STRING: Check = (v) => typeof v === "string";
const IS_NUMBER: Check = (v) => typeof v === "number";
const IS_INTEGER: Check = (v) => Number.isInteger(v);
const IS_BOOLEAN: Check = (v) => typeof v === "boolean";
const IS_NULL: Check = (v) => v === null;
export const IS_ARRAY: Check = (v) => Array.isArray(v);
const IS_BYTES: Check = (v) => v instanceof Uint8Array;
export const IS_OBJECT: Check = (v) => isObject(v);
/** Whether `v` is a JSON object: not null, an array or bytes. */
export function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Uint8Array);
}
/** The check of a `type` name; `bytes` (a Uint8Array) extends JSON Schema for values that never pass through JSON. */
export const TYPE_CHECKS: ReadonlyMap<string, Check> = new Map([
["string", IS_STRING],
["number", IS_NUMBER],
["integer", IS_INTEGER],
["boolean", IS_BOOLEAN],
["null", IS_NULL],
["array", IS_ARRAY],
["object", IS_OBJECT],
["bytes", IS_BYTES],
]);
/** Leaf checks that object and array loops run inline by code instead of calling them. */
export const LEAF_CODES: ReadonlyMap<Check, number> = new Map([
[IS_STRING, 1],
[IS_NUMBER, 2],
[IS_INTEGER, 3],
[IS_BOOLEAN, 4],
[IS_NULL, 5],
[ALWAYS, 6],
]);
function leafOk(code: number, x: unknown): boolean {
switch (code) {
case 1:
return typeof x === "string";
case 2:
return typeof x === "number";
case 3:
return Number.isInteger(x);
case 4:
return typeof x === "boolean";
case 5:
return x === null;
default:
return true;
}
}
/** Whether a combined object or array check also checks the type: not at all, strictly, or passing other types. */
export const GUARD_NONE = 0;
export const GUARD_STRICT = 1;
export const GUARD_LOOSE = 2;
export type Guard = typeof GUARD_NONE | typeof GUARD_STRICT | typeof GUARD_LOOSE;
/** Flags of a declared property in an object check's entries: its leaf code and two bits. */
export const CODE = 7;
export const REQUIRED = 8;
export const INHERITED = 16;
/** All of `parts`; two and three parts get closures without a loop. */
export function and(parts: readonly Check[]): Check {
switch (parts.length) {
case 0:
return ALWAYS;
case 1:
return parts[0];
case 2: {
const [a, b] = parts;
return (v) => a(v) && b(v);
}
case 3: {
const [a, b, c] = parts;
return (v) => a(v) && b(v) && c(v);
}
default:
return (v) => {
for (let i = 0; i < parts.length; i++) if (!parts[i](v)) return false;
return true;
};
}
}
/** A check whose implementation is set later: the indirection a recursive reference goes through. */
export function indirect(slot: { check: Check | undefined }): Check {
return (v) => (slot.check as Check)(v);
}
/** `check` with `uri` pushed onto the dynamic scope while it runs (only for schemas with `$dynamicRef`). */
export function scopedCheck(scope: string[], uri: string, check: Check): Check {
return (v) => {
scope.push(uri);
const ok = check(v);
scope.pop();
return ok;
};
}
/** Any of the named types. */
export function typeCheck(types: readonly unknown[]): Check {
const checks = types.map((t) => (typeof t === "string" ? TYPE_CHECKS.get(t) : undefined) ?? NEVER);
if (checks.length === 1) return checks[0];
return (v) => {
for (let i = 0; i < checks.length; i++) if (checks[i](v)) return true;
return false;
};
}
/** A Uint8Array within the length bounds. */
export function bytesCheck(min: number, max: number): Check {
return (v) => v instanceof Uint8Array && v.length >= min && v.length <= max;
}
/** The number of Unicode code points of `s`: JSON Schema string lengths count code points, not UTF-16 units. */
export function codePoints(s: string): number {
let n = 0;
for (let i = 0; i < s.length; i++, n++) {
const c = s.charCodeAt(i);
if (c >= 0xd800 && c <= 0xdbff) i++;
}
return n;
}
// UTF-16 units bound code points from both sides (units / 2 <= points <= units), so most strings need no count.
function lengthOk(s: string, min: number, max: number): boolean {
const units = s.length;
if (units < min) return false;
if (units < 2 * min && codePoints(s) < min) return false;
return units <= max || codePoints(s) <= max;
}
/** The string keywords: length bounds, pattern and format. */
export function stringCheck(
strict: boolean,
min: number,
max: number,
re: RegExp | undefined,
format: ((value: string) => boolean) | undefined,
): Check {
if (re !== undefined && min === 0 && max === Number.POSITIVE_INFINITY && format === undefined) {
return strict ? (v) => typeof v === "string" && re.test(v) : (v) => typeof v !== "string" || re.test(v);
}
if (re === undefined && format === undefined) {
return strict
? (v) => typeof v === "string" && lengthOk(v, min, max)
: (v) => typeof v !== "string" || lengthOk(v, min, max);
}
return (v) => {
if (typeof v !== "string") return !strict;
return lengthOk(v, min, max) && (re === undefined || re.test(v)) && (format === undefined || format(v));
};
}
/** The number keywords. `mode`: 0 passes other types, 1 requires a number, 2 an integer. */
export function numberCheck(
mode: 0 | 1 | 2,
min: number,
max: number,
xmin: number,
xmax: number,
multipleOf: number | undefined,
): Check {
return (v) => {
if (typeof v !== "number") return mode === 0;
if (mode === 2 && !Number.isInteger(v)) return false;
return v >= min && v <= max && v > xmin && v < xmax && (multipleOf === undefined || isMultiple(v, multipleOf));
};
}
/** Whether `v` is a multiple of `d`, exactly for decimals: 0.0075 / 0.0001 is 74.99999999999999 in floating point. */
export function isMultiple(v: number, d: number): boolean {
const q = v / d;
if (Number.isInteger(q)) return true;
if (!Number.isFinite(q)) return v % d === 0;
const [vc, ve] = decimal(v);
const [dc, de] = decimal(d);
return ve >= de ? (vc * 10n ** BigInt(ve - de)) % dc === 0n : vc % (dc * 10n ** BigInt(de - ve)) === 0n;
}
function decimal(x: number): [bigint, number] {
const [mantissa, exponent = "0"] = Math.abs(x).toString().split("e");
const point = mantissa.indexOf(".");
const fraction = point < 0 ? 0 : mantissa.length - point - 1;
return [BigInt(mantissa.replace(".", "")), Number(exponent) - fraction];
}
/** `inner` for objects; other values pass unless `strict`. */
export function guardObject(inner: Check, strict: boolean): Check {
return strict ? (v) => isObject(v) && inner(v) : (v) => !isObject(v) || inner(v);
}
/** `inner` for arrays; other values pass unless `strict`. */
export function guardArray(inner: Check, strict: boolean): Check {
return strict ? (v) => Array.isArray(v) && inner(v) : (v) => !Array.isArray(v) || inner(v);
}
/**
* An object with only declared properties (`additionalProperties: false`). `entries` is `[key, check, flags]` per
* property. Declared fields are checked, then the own keys are counted, so unknown keys are found without
* allocating. A property whose value is `undefined` counts as absent.
*/
export function closedObject(entries: readonly unknown[], guard: Guard): Check {
return (v) => {
if (guard !== GUARD_NONE && !isObject(v)) return guard === GUARD_LOOSE;
// Guarded: by the check above, or by the caller for GUARD_NONE.
const o = v as Record<string, unknown>;
let present = 0;
for (let i = 0; i < entries.length; i += 3) {
const k = entries[i] as string;
const x = o[k];
const f = entries[i + 2] as number;
if (x === undefined) {
if (f & REQUIRED) return false;
if (Object.hasOwn(o, k)) present++;
continue;
}
if (f & INHERITED && !Object.hasOwn(o, k)) {
if (f & REQUIRED) return false;
continue;
}
present++;
const c = f & CODE;
if (c !== 0 ? !leafOk(c, x) : !(entries[i + 1] as Check)(x)) return false;
}
let count = 0;
for (const _ in o) if (++count > present) return false;
return true;
};
}
/** An object with declared properties, required keys not declared, pattern properties and additional properties. */
export function openObject(
entries: readonly unknown[],
keys: readonly string[],
extraRequired: readonly string[],
patterns: readonly (readonly [RegExp, Check])[],
additional: Check | undefined,
guard: Guard,
): Check {
const known = patterns.length > 0 || additional !== undefined ? new Set(keys) : undefined;
return (v) => {
if (guard !== GUARD_NONE && !isObject(v)) return guard === GUARD_LOOSE;
const o = v as Record<string, unknown>;
for (let i = 0; i < entries.length; i += 3) {
const k = entries[i] as string;
const x = o[k];
const f = entries[i + 2] as number;
if (x === undefined || (f & INHERITED && !Object.hasOwn(o, k))) {
if (f & REQUIRED) return false;
continue;
}
const c = f & CODE;
if (c !== 0 ? !leafOk(c, x) : !(entries[i + 1] as Check)(x)) return false;
}
for (let i = 0; i < extraRequired.length; i++) if (!Object.hasOwn(o, extraRequired[i])) return false;
if (known === undefined) return true;
for (const k of Object.keys(o)) {
let matched = known.has(k);
for (let i = 0; i < patterns.length; i++) {
if (patterns[i][0].test(k)) {
matched = true;
if (!patterns[i][1](o[k])) return false;
}
}
if (!matched && additional !== undefined && !additional(o[k])) return false;
}
return true;
};
}
/** Every property name passes `check` (`v` is an object). */
export function propertyNamesCheck(check: Check): Check {
return (v) => {
for (const k in v as object) if (!check(k)) return false;
return true;
};
}
/** `minProperties` and `maxProperties` (`v` is an object). */
export function propertyCountCheck(min: number, max: number): Check {
return (v) => {
let n = 0;
for (const _ in v as object) n++;
return n >= min && n <= max;
};
}
/** `dependentRequired` (`v` is an object). */
export function dependentRequiredCheck(entries: readonly (readonly [string, readonly string[]])[]): Check {
return (v) => {
const o = v as object;
for (const [k, list] of entries)
if (Object.hasOwn(o, k)) for (const r of list) if (!Object.hasOwn(o, r)) return false;
return true;
};
}
/** `dependentSchemas` (`v` is an object). */
export function dependentSchemasCheck(entries: readonly (readonly [string, Check])[]): Check {
return (v) => {
for (const [k, check] of entries) if (Object.hasOwn(v as object, k) && !check(v)) return false;
return true;
};
}
/** The common array: guard, length bounds, prefix items and the items after them, in one closure. */
export function listCheck(
guard: Guard,
min: number,
max: number,
prefix: readonly Check[],
rest: Check | undefined,
): Check {
const n = prefix.length;
const code = rest === undefined ? 0 : (LEAF_CODES.get(rest) ?? 0);
return (v) => {
if (!Array.isArray(v)) return guard === GUARD_LOOSE;
const length = v.length;
if (length < min || length > max) return false;
const m = length < n ? length : n;
for (let i = 0; i < m; i++) if (!prefix[i](v[i])) return false;
if (rest === undefined) return true;
if (code !== 0) {
for (let i = n; i < length; i++) if (!leafOk(code, v[i])) return false;
return true;
}
for (let i = n; i < length; i++) if (!rest(v[i])) return false;
return true;
};
}
/** Prefix items and the items after them (`v` is an array). */
export function itemsCheck(prefix: readonly Check[], rest: Check | undefined): Check {
return listCheck(GUARD_NONE, 0, Number.POSITIVE_INFINITY, prefix, rest);
}
/** `minItems` and `maxItems` (`v` is an array). */
export function lengthCheck(min: number, max: number): Check {
return (v) => (v as unknown[]).length >= min && (v as unknown[]).length <= max;
}
/** `contains` with its bounds (`v` is an array). */
export function containsCheck(check: Check, min: number, max: number): Check {
return (v) => {
const list = v as unknown[];
let n = 0;
for (let i = 0; i < list.length; i++) if (check(list[i]) && ++n > max) return false;
return n >= min;
};
}
/** `uniqueItems` (`v` is an array). */
export function uniqueCheck(v: unknown): boolean {
const list = v as unknown[];
for (let i = 1; i < list.length; i++) for (let j = 0; j < i; j++) if (deepEqual(list[i], list[j])) return false;
return true;
}
/** `enum`: up to 8 primitives by a scan, more by a Set, objects and arrays by deep equality. */
export function enumCheck(values: readonly unknown[]): Check {
const primitives = values.filter((x) => typeof x !== "object" || x === null);
if (primitives.length === values.length && primitives.length <= 8) {
return (v) => {
for (let i = 0; i < primitives.length; i++) if (primitives[i] === v) return true;
return false;
};
}
const set = new Set(primitives);
const objects = values.filter((x) => typeof x === "object" && x !== null);
return (v) => set.has(v) || (typeof v === "object" && objects.some((o) => deepEqual(v, o)));
}
/** `const`. */
export function constCheck(value: unknown): Check {
if (typeof value !== "object" || value === null) return (v) => v === value;
return (v) => deepEqual(v, value);
}
/** `anyOf`. */
export function anyCheck(checks: readonly Check[]): Check {
return (v) => {
for (let i = 0; i < checks.length; i++) if (checks[i](v)) return true;
return false;
};
}
/** `oneOf`. */
export function oneCheck(checks: readonly Check[]): Check {
return (v) => {
let n = 0;
for (let i = 0; i < checks.length; i++) if (checks[i](v) && ++n > 1) return false;
return n === 1;
};
}
/** `not`. */
export function notCheck(check: Check): Check {
return (v) => !check(v);
}
/** `if`, `then` and `else`. */
export function ifCheck(cond: Check, then: Check, otherwise: Check): Check {
return (v) => (cond(v) ? then(v) : otherwise(v));
}
/** A union of objects tagged by a const on `key`: the tag selects the one branch that can match. */
export function taggedCheck(key: string, tags: readonly unknown[], checks: readonly Check[]): Check {
return (v) => {
if (!isObject(v)) return false;
const tag = v[key];
for (let i = 0; i < tags.length; i++) if (tags[i] === tag) return checks[i](v);
return false;
};
}
/** JSON equality: same primitives, arrays with equal items in order, objects with the same keys and equal values. */
export function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
const array = Array.isArray(a);
if (array !== Array.isArray(b)) return false;
if (array) {
const x = a as unknown[];
const y = b as unknown[];
if (x.length !== y.length) return false;
for (let i = 0; i < x.length; i++) if (!deepEqual(x[i], y[i])) return false;
return true;
}
const x = a as Record<string, unknown>;
const y = b as Record<string, unknown>;
const keys = Object.keys(x);
if (keys.length !== Object.keys(y).length) return false;
for (const k of keys) if (!Object.hasOwn(y, k) || !deepEqual(x[k], y[k])) return false;
return true;
}

View file

@ -0,0 +1,618 @@
// The compiler: a schema to one check closure, built once. Keywords group by the type they constrain, so a value
// is classified once per schema, and the common shapes (closed objects, arrays of one item schema, tagged unions,
// leaf schemas) get one specialized closure each.
import {
ANNOTATING_FALSE,
ANNOTATING_TRUE,
type AnnotatingCheck,
annotateAll,
annotateAny,
annotateArray,
annotateDependent,
annotateIf,
annotateObject,
ignoreAnnotations,
indirectAnnotating,
isolateAnnotations,
scopedAnnotating,
unevaluatedItems,
unevaluatedProperties,
withFreshAnnotations,
} from "./annotations.ts";
import {
ALWAYS,
and,
anyCheck,
bytesCheck,
closedObject,
constCheck,
containsCheck,
dependentRequiredCheck,
dependentSchemasCheck,
enumCheck,
GUARD_LOOSE,
GUARD_NONE,
GUARD_STRICT,
type Guard,
guardArray,
guardObject,
INHERITED,
IS_ARRAY,
IS_OBJECT,
ifCheck,
indirect,
itemsCheck,
LEAF_CODES,
lengthCheck,
listCheck,
NEVER,
notCheck,
numberCheck,
oneCheck,
openObject,
propertyCountCheck,
propertyNamesCheck,
REQUIRED,
scopedCheck,
stringCheck,
taggedCheck,
typeCheck,
uniqueCheck,
} from "./checks.ts";
import { documentOf, isSchemaObject, Resolver, resolveUri } from "./resolver.ts";
import { sharedCheck, sharedRegex } from "./shared.ts";
import type { Check, CompileOptions, FormatCheck, JsonSchema, SchemaObject } from "./types.ts";
/** Thrown when a compile without dynamic scope tracking meets a bookended `$dynamicRef`: compile again with it. */
export const RESTART = Symbol("restart");
/** One compile of a schema. Its caches and indexes are garbage once the root check is built. */
export class Compiler {
readonly r: Resolver;
readonly draft: "2020-12" | "7";
readonly formats: Readonly<Record<string, FormatCheck>> | undefined;
/** Whether checks track the dynamic scope (the schema has a bookended `$dynamicRef`). */
readonly dynamic: boolean;
private readonly schema: JsonSchema;
private readonly scope: string[];
private readonly cache = new Map<object, Check>();
private readonly annotatingCache = new Map<object, AnnotatingCheck>();
constructor(schema: JsonSchema, options: CompileOptions, dynamic: boolean) {
this.schema = schema;
this.r = new Resolver(schema, options);
this.draft = this.r.draft;
this.formats = options.formats;
this.dynamic = dynamic;
this.scope = [];
}
/** The check of the root schema. */
root(): Check {
return this.check(this.schema, this.r.rootBase);
}
/** The check of a schema (cached by schema object; a recursive reference goes through an indirection). */
check(node: unknown, base: string): Check {
if (node === true) return ALWAYS;
if (node === false) return NEVER;
if (!isSchemaObject(node))
throw new Error(`A schema must be an object or a boolean, got ${JSON.stringify(node)}`);
const cached = this.cache.get(node);
if (cached !== undefined) return cached;
const key = this.leafKey(node);
if (key !== undefined) {
const shared = sharedCheck(key, () => this.plain(node, base));
this.cache.set(node, shared);
return shared;
}
const slot: { check: Check | undefined } = { check: undefined };
this.cache.set(node, indirect(slot));
const check = this.build(node, this.r.baseOf.get(node) ?? base);
slot.check = check;
this.cache.set(node, check);
return check;
}
private build(node: SchemaObject, base: string): Check {
if (this.draft === "7" && typeof node.$ref === "string") return this.ref(node.$ref, base);
if (this.draft !== "7" && (node.unevaluatedProperties !== undefined || node.unevaluatedItems !== undefined)) {
return withFreshAnnotations(this.annotating(node, base));
}
const check = this.plain(node, base);
return this.dynamic && this.r.isResource(node, base) ? scopedCheck(this.scope, base, check) : check;
}
/**
* The sharing key of a leaf schema (only assertion keywords; no subschemas, references or identifiers): equal leaves
* in all compiled schemas share one check. Undefined for every other schema.
*/
private leafKey(node: SchemaObject): string | undefined {
const picked: Record<string, unknown> = {};
for (const k of Object.keys(node)) {
if (LEAF_KEYWORDS.has(k)) picked[k] = node[k];
else if (!NEUTRAL_KEYWORDS.has(k)) return undefined;
}
// A format checks through this compile's format map, so a leaf with one is shared only while formats are off.
if (picked.format !== undefined && this.formats !== undefined) return undefined;
return this.draft + JSON.stringify(picked);
}
/** Every keyword of `node`, not tracking annotations. */
private plain(node: SchemaObject, base: string): Check {
const parts: Check[] = [];
const types = node.type === undefined ? undefined : Array.isArray(node.type) ? node.type : [node.type];
const single = types?.length === 1 ? types[0] : undefined;
let typeInGroup = false;
if (single === "bytes") {
parts.push(bytesCheck(number(node.minLength) ?? 0, number(node.maxLength) ?? Number.POSITIVE_INFINITY));
typeInGroup = true;
} else {
const groups: [Check | undefined, boolean][] = [
[this.stringGroup(node, single === "string"), single === "string"],
[
this.numberGroup(node, single === "number" ? 1 : single === "integer" ? 2 : 0),
single === "number" || single === "integer",
],
[this.objectGroup(node, base, single === "object"), single === "object"],
[this.arrayGroup(node, base, single === "array"), single === "array"],
];
for (const [group, strict] of groups) {
if (group === undefined) continue;
parts.push(group);
if (strict) typeInGroup = true;
}
}
if (types !== undefined && !typeInGroup) parts.unshift(typeCheck(types));
this.inPlace(node, base, parts);
return and(parts);
}
// The keywords that apply to the value itself, whatever its type.
private inPlace(node: SchemaObject, base: string, parts: Check[]): void {
if (Array.isArray(node.enum)) parts.push(enumCheck(node.enum));
if (Object.hasOwn(node, "const")) parts.push(constCheck(node.const));
if (Array.isArray(node.allOf)) parts.push(and(node.allOf.map((s) => this.check(s, base))));
if (Array.isArray(node.anyOf)) parts.push(this.union(node.anyOf, base, false));
if (Array.isArray(node.oneOf)) parts.push(this.union(node.oneOf, base, true));
if (node.not !== undefined) parts.push(notCheck(this.check(node.not, base)));
if (node.if !== undefined && (node.then !== undefined || node.else !== undefined)) {
const then = node.then === undefined ? ALWAYS : this.check(node.then, base);
const otherwise = node.else === undefined ? ALWAYS : this.check(node.else, base);
parts.push(ifCheck(this.check(node.if, base), then, otherwise));
}
if (typeof node.$ref === "string") parts.push(this.ref(node.$ref, base));
if (this.draft !== "7" && typeof node.$dynamicRef === "string") {
parts.push(this.dynamicRef(node.$dynamicRef, base));
}
}
private union(branches: readonly unknown[], base: string, exactlyOne: boolean): Check {
const checks = () => branches.map((s) => this.check(s, base));
return (
constUnion(branches, exactlyOne) ??
this.tagged(branches, base) ??
(exactlyOne ? oneCheck(checks()) : anyCheck(checks()))
);
}
private ref(ref: string, base: string): Check {
const target = this.r.resolve(ref, base);
const check = this.check(target.node, target.base);
return this.dynamic && target.base !== base ? scopedCheck(this.scope, target.base, check) : check;
}
private dynamicRef(ref: string, base: string): Check {
const resolved = this.bookended(ref, base);
if (resolved === undefined) return this.ref(ref, base);
const { initial, name } = resolved;
const fallback = this.check(initial.node, initial.base);
const lookup = this.dynamicLookup(name, (node, b) => this.check(node, b));
const scope = this.scope;
return (v) => {
for (let i = 0; i < scope.length; i++) {
const check = lookup(scope[i]);
if (check !== null) return check(v);
}
return fallback(v);
};
}
/** The initial target and anchor name of a `$dynamicRef` that resolves dynamically, or undefined for a plain ref. */
private bookended(
ref: string,
base: string,
): { initial: { node: unknown; base: string }; name: string } | undefined {
const initial = this.r.resolve(ref, base);
const uri = resolveUri(ref, base);
const name = uri.slice(documentOf(uri).length + 1);
const anchored = isSchemaObject(initial.node) && initial.node.$dynamicAnchor === name;
if (name === "" || name.startsWith("/") || !anchored) return undefined;
if (!this.dynamic) throw RESTART;
return { initial, name };
}
// Resolved at run time against the dynamic scope, so a schema with a bookended $dynamicRef keeps its compiler.
private dynamicLookup<T>(name: string, compile: (node: SchemaObject, base: string) => T): (uri: string) => T | null {
const found = new Map<string, T | null>();
return (uri) => {
const key = `${uri}#${name}`;
let check = found.get(key);
if (check === undefined) {
const target = this.r.dynamicAnchors.get(key);
check = target === undefined ? null : compile(target, this.r.baseOf.get(target) ?? uri);
found.set(key, check);
}
return check;
};
}
/** anyOf/oneOf whose branches are all objects with a distinct required const on one key: the tag picks the branch. */
private tagged(branches: readonly unknown[], base: string): Check | undefined {
if (branches.length < 2) return undefined;
const resolved: SchemaObject[] = [];
for (const branch of branches) {
if (!isSchemaObject(branch)) return undefined;
const ref = typeof branch.$ref === "string" && Object.keys(branch).length === 1 ? branch.$ref : undefined;
const node = ref === undefined ? branch : this.r.resolve(ref, this.r.baseOf.get(branch) ?? base).node;
if (!isSchemaObject(node)) return undefined;
resolved.push(node);
}
const first = resolved[0].properties;
if (!isSchemaObject(first)) return undefined;
for (const key of Object.keys(first)) {
const tags = resolved.map((s) => tagOf(s, key));
if (tags.some((t) => t === undefined) || new Set(tags).size !== tags.length) continue;
return taggedCheck(
key,
tags,
branches.map((s) => this.check(s, base)),
);
}
return undefined;
}
private stringGroup(node: SchemaObject, strict: boolean): Check | undefined {
const min = number(node.minLength) ?? 0;
const max = number(node.maxLength) ?? Number.POSITIVE_INFINITY;
const re = typeof node.pattern === "string" ? sharedRegex(node.pattern) : undefined;
const format =
this.formats !== undefined && typeof node.format === "string" && Object.hasOwn(this.formats, node.format)
? this.formats[node.format]
: undefined;
if (min === 0 && max === Number.POSITIVE_INFINITY && re === undefined && format === undefined) return undefined;
return stringCheck(strict, min, max, re, format);
}
private numberGroup(node: SchemaObject, mode: 0 | 1 | 2): Check | undefined {
const min = number(node.minimum);
const max = number(node.maximum);
const xmin = number(node.exclusiveMinimum);
const xmax = number(node.exclusiveMaximum);
const multipleOf = number(node.multipleOf);
if ([min, max, xmin, xmax, multipleOf].every((x) => x === undefined)) return undefined;
const inf = Number.POSITIVE_INFINITY;
return numberCheck(mode, min ?? -inf, max ?? inf, xmin ?? -inf, xmax ?? inf, multipleOf);
}
private objectGroup(node: SchemaObject, base: string, strict: boolean): Check | undefined {
const onlyCore = OBJECT_EXTRAS.every((k) => node[k] === undefined);
// A schema with only the core object keywords gets one closure that also does the type guard.
const core = this.objectCore(node, base, onlyCore ? (strict ? GUARD_STRICT : GUARD_LOOSE) : GUARD_NONE);
if (core !== undefined && onlyCore) return core;
const parts: Check[] = core === undefined ? [] : [core];
if (node.propertyNames !== undefined) parts.push(propertyNamesCheck(this.check(node.propertyNames, base)));
const minProperties = number(node.minProperties);
const maxProperties = number(node.maxProperties);
if (minProperties !== undefined || maxProperties !== undefined) {
parts.push(propertyCountCheck(minProperties ?? 0, maxProperties ?? Number.POSITIVE_INFINITY));
}
const dependentRequired: [string, string[]][] = [];
const dependentSchemas: [string, Check][] = [];
if (this.draft !== "7") {
for (const [k, list] of entries(node.dependentRequired))
if (Array.isArray(list)) dependentRequired.push([k, list]);
for (const [k, s] of entries(node.dependentSchemas)) dependentSchemas.push([k, this.check(s, base)]);
}
for (const [k, d] of entries(node.dependencies)) {
if (Array.isArray(d)) dependentRequired.push([k, d]);
else dependentSchemas.push([k, this.check(d, base)]);
}
if (dependentRequired.length > 0) parts.push(dependentRequiredCheck(dependentRequired));
if (dependentSchemas.length > 0) parts.push(dependentSchemasCheck(dependentSchemas));
if (parts.length === 0) return strict ? IS_OBJECT : undefined;
return guardObject(and(parts), strict);
}
private objectCore(node: SchemaObject, base: string, guard: Guard): Check | undefined {
const properties = isSchemaObject(node.properties) ? node.properties : {};
const required = Array.isArray(node.required) ? node.required.filter((k) => typeof k === "string") : [];
const patterns = isSchemaObject(node.patternProperties) ? node.patternProperties : {};
const additional = node.additionalProperties;
const keys = Object.keys(properties);
const patternKeys = Object.keys(patterns);
if (
keys.length + required.length + patternKeys.length === 0 &&
(additional === undefined || additional === true)
) {
return undefined;
}
// One array per object: key, check, flags (leaf code, required, key also names an Object.prototype member).
const entries: unknown[] = [];
for (const k of keys) {
const check = this.check(properties[k], base);
const flags =
(LEAF_CODES.get(check) ?? 0) |
(required.includes(k) ? REQUIRED : 0) |
(k in Object.prototype ? INHERITED : 0);
entries.push(k, check, flags);
}
const extraRequired = required.filter((k) => !Object.hasOwn(properties, k));
const pats = patternKeys.map((p) => [sharedRegex(p), this.check(patterns[p], base)] as const);
if (additional === false && pats.length === 0 && extraRequired.length === 0) return closedObject(entries, guard);
const extra = additional === undefined || additional === true ? undefined : this.check(additional, base);
return openObject(entries, keys, extraRequired, pats, extra, guard);
}
private arrayGroup(node: SchemaObject, base: string, strict: boolean): Check | undefined {
let prefix: readonly unknown[] = [];
let rest: unknown;
if (Array.isArray(node.items)) {
prefix = node.items;
rest = node.additionalItems;
} else {
if (this.draft !== "7" && Array.isArray(node.prefixItems)) prefix = node.prefixItems;
rest = node.items;
}
const prefixChecks = prefix.map((s) => this.check(s, base));
const restCheck = rest === undefined || rest === true ? undefined : this.check(rest, base);
const minItems = number(node.minItems);
const maxItems = number(node.maxItems);
if (
node.contains === undefined &&
node.uniqueItems !== true &&
(prefixChecks.length > 0 || restCheck !== undefined)
) {
// The common array: one closure for the guard, the length bounds and the items.
const guard = strict ? GUARD_STRICT : GUARD_LOOSE;
return listCheck(guard, minItems ?? 0, maxItems ?? Number.POSITIVE_INFINITY, prefixChecks, restCheck);
}
const parts: Check[] = [];
if (prefixChecks.length > 0 || restCheck !== undefined) parts.push(itemsCheck(prefixChecks, restCheck));
if (minItems !== undefined || maxItems !== undefined) {
parts.push(lengthCheck(minItems ?? 0, maxItems ?? Number.POSITIVE_INFINITY));
}
if (node.contains !== undefined) {
const min = this.draft === "7" ? 1 : (number(node.minContains) ?? 1);
const max =
this.draft === "7" ? Number.POSITIVE_INFINITY : (number(node.maxContains) ?? Number.POSITIVE_INFINITY);
parts.push(containsCheck(this.check(node.contains, base), min, max));
}
if (node.uniqueItems === true) parts.push(uniqueCheck);
if (parts.length === 0) return strict ? IS_ARRAY : undefined;
return guardArray(and(parts), strict);
}
/** The annotating check of a schema evaluated in place (only below `unevaluatedProperties`/`unevaluatedItems`). */
annotating(node: unknown, base: string): AnnotatingCheck {
if (node === true) return ANNOTATING_TRUE;
if (node === false) return ANNOTATING_FALSE;
if (!isSchemaObject(node))
throw new Error(`A schema must be an object or a boolean, got ${JSON.stringify(node)}`);
const cached = this.annotatingCache.get(node);
if (cached !== undefined) return cached;
const slot: { check: AnnotatingCheck | undefined } = { check: undefined };
this.annotatingCache.set(node, indirectAnnotating(slot));
const b = this.r.baseOf.get(node) ?? base;
const check = this.buildAnnotating(node, b);
slot.check = check;
this.annotatingCache.set(node, check);
return check;
}
private buildAnnotating(node: SchemaObject, base: string): AnnotatingCheck {
const rest: Record<string, unknown> = {};
let annotates = false;
for (const k of Object.keys(node)) {
if (ANNOTATING.has(k)) annotates = true;
else rest[k] = node[k];
}
const plain = this.plain(rest, base);
const parts: AnnotatingCheck[] = [];
if (annotates) this.annotatingParts(node, base, parts);
const all = parts.length === 0 ? ignoreAnnotations(plain) : annotateAll(plain, parts);
const own =
node.unevaluatedProperties !== undefined || node.unevaluatedItems !== undefined
? isolateAnnotations(all)
: all;
return this.dynamic && this.r.isResource(node, base) ? scopedAnnotating(this.scope, base, own) : own;
}
private annotatingParts(node: SchemaObject, base: string, parts: AnnotatingCheck[]): void {
if (
node.properties !== undefined ||
node.patternProperties !== undefined ||
node.additionalProperties !== undefined
) {
const props = entries(node.properties).map(([k, s]) => [k, this.check(s, base)] as const);
const pats = entries(node.patternProperties).map(([p, s]) => [sharedRegex(p), this.check(s, base)] as const);
const extra =
node.additionalProperties === undefined ? undefined : this.check(node.additionalProperties, base);
parts.push(annotateObject(props, pats, extra));
}
if (node.prefixItems !== undefined || node.items !== undefined || node.contains !== undefined) {
const prefix = Array.isArray(node.prefixItems)
? node.prefixItems
: Array.isArray(node.items)
? node.items
: [];
const rest = Array.isArray(node.items) ? node.additionalItems : node.items;
parts.push(
annotateArray(
prefix.map((s) => this.check(s, base)),
rest === undefined ? undefined : this.check(rest, base),
node.contains === undefined ? undefined : this.check(node.contains, base),
number(node.minContains) ?? 1,
number(node.maxContains) ?? Number.POSITIVE_INFINITY,
),
);
}
if (Array.isArray(node.allOf)) for (const s of node.allOf) parts.push(this.annotating(s, base));
if (Array.isArray(node.anyOf))
parts.push(
annotateAny(
node.anyOf.map((s) => this.annotating(s, base)),
false,
),
);
if (Array.isArray(node.oneOf))
parts.push(
annotateAny(
node.oneOf.map((s) => this.annotating(s, base)),
true,
),
);
if (node.if !== undefined) {
const then = node.then === undefined ? undefined : this.annotating(node.then, base);
const otherwise = node.else === undefined ? undefined : this.annotating(node.else, base);
parts.push(annotateIf(this.annotating(node.if, base), then, otherwise));
}
if (typeof node.$ref === "string") parts.push(this.annotatingRef(node.$ref, base));
if (typeof node.$dynamicRef === "string") parts.push(this.annotatingDynamicRef(node.$dynamicRef, base));
for (const [k, s] of entries(node.dependentSchemas)) parts.push(annotateDependent(k, this.annotating(s, base)));
if (node.unevaluatedProperties !== undefined) {
parts.push(unevaluatedProperties(this.check(node.unevaluatedProperties, base)));
}
if (node.unevaluatedItems !== undefined) parts.push(unevaluatedItems(this.check(node.unevaluatedItems, base)));
}
private annotatingRef(ref: string, base: string): AnnotatingCheck {
const target = this.r.resolve(ref, base);
const check = this.annotating(target.node, target.base);
return this.dynamic && target.base !== base ? scopedAnnotating(this.scope, target.base, check) : check;
}
private annotatingDynamicRef(ref: string, base: string): AnnotatingCheck {
const resolved = this.bookended(ref, base);
if (resolved === undefined) return this.annotatingRef(ref, base);
const { initial, name } = resolved;
const fallback = this.annotating(initial.node, initial.base);
const lookup = this.dynamicLookup(name, (node, b) => this.annotating(node, b));
const scope = this.scope;
return (v, a) => {
for (let i = 0; i < scope.length; i++) {
const check = lookup(scope[i]);
if (check !== null) return check(v, a);
}
return fallback(v, a);
};
}
}
// Keywords whose check depends only on their own value, and keywords that never affect a check.
const LEAF_KEYWORDS = new Set([
"type",
"enum",
"const",
"minLength",
"maxLength",
"pattern",
"format",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
"required",
"dependentRequired",
]);
/** Keywords that never affect a check. */
export const NEUTRAL_KEYWORDS = new Set([
"$schema",
"$comment",
"title",
"description",
"default",
"examples",
"deprecated",
"readOnly",
"writeOnly",
"$defs",
"definitions",
"contentEncoding",
"contentMediaType",
"minContains",
"maxContains",
"$vocabulary",
]);
// The object keywords besides the core (properties, required, patternProperties, additionalProperties).
const OBJECT_EXTRAS = [
"propertyNames",
"minProperties",
"maxProperties",
"dependentRequired",
"dependentSchemas",
"dependencies",
];
// The keywords an annotating check handles itself; every other keyword compiles into its plain part.
const ANNOTATING = new Set([
"properties",
"patternProperties",
"additionalProperties",
"prefixItems",
"items",
"additionalItems",
"contains",
"minContains",
"maxContains",
"allOf",
"anyOf",
"oneOf",
"if",
"then",
"else",
"$ref",
"$dynamicRef",
"dependentSchemas",
"unevaluatedProperties",
"unevaluatedItems",
]);
/** anyOf/oneOf whose branches are all `{ const: primitive }`: one shared enum (distinct values make oneOf the same). */
function constUnion(branches: readonly unknown[], exactlyOne: boolean): Check | undefined {
const values: unknown[] = [];
for (const branch of branches) {
if (!isSchemaObject(branch) || !Object.hasOwn(branch, "const")) return undefined;
for (const k of Object.keys(branch)) if (k !== "const" && !NEUTRAL_KEYWORDS.has(k)) return undefined;
if (typeof branch.const === "object" && branch.const !== null) return undefined;
if (exactlyOne && values.includes(branch.const)) return undefined;
values.push(branch.const);
}
return sharedCheck(`enum${JSON.stringify(values)}`, () => enumCheck(values));
}
// The tag of a tagged-union branch: a primitive const on a required property of an object schema.
function tagOf(branch: SchemaObject, key: string): unknown {
const properties = branch.properties;
if (branch.type !== "object" || !Array.isArray(branch.required) || !branch.required.includes(key)) return undefined;
const tag = isSchemaObject(properties) ? properties[key] : undefined;
if (!isSchemaObject(tag) || !Object.hasOwn(tag, "const")) return undefined;
return typeof tag.const === "object" && tag.const !== null ? undefined : tag.const;
}
function number(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
function entries(value: unknown): [string, unknown][] {
return isSchemaObject(value) ? Object.entries(value) : [];
}

View file

@ -0,0 +1,327 @@
// Errors of a failed value: a walk over the schema that reports each failing keyword at the value it failed on.
// It runs only for values that failed the check, using the compiler's checks for subschemas.
import { newAnnotations } from "./annotations.ts";
import {
codePoints,
constCheck,
containsCheck,
enumCheck,
isMultiple,
isObject,
NEVER,
TYPE_CHECKS,
uniqueCheck,
} from "./checks.ts";
import type { Compiler } from "./compiler.ts";
import { isSchemaObject } from "./resolver.ts";
import { sharedRegex } from "./shared.ts";
import type { SchemaObject, ValidationError } from "./types.ts";
/** Explains failures of one compiled schema. */
export class Explainer {
private readonly c: Compiler;
private readonly schema: unknown;
constructor(compiler: Compiler, schema: unknown) {
this.c = compiler;
this.schema = schema;
compiler.root();
}
/** The errors of `value`, at least one (`value` failed the check). */
errors(value: unknown): ValidationError[] {
const out: ValidationError[] = [];
this.walk(this.schema, this.c.r.rootBase, value, "", out);
if (out.length === 0) out.push({ path: "", keyword: "schema", message: "invalid" });
return out;
}
private ok(node: unknown, base: string, v: unknown): boolean {
return this.c.check(node, base)(v);
}
// For $dynamicRef schemas this check runs outside the original dynamic scope, so their errors are approximate.
private walk(node: unknown, base: string, v: unknown, path: string, out: ValidationError[]): void {
if (node === true) return;
if (node === false) {
out.push({ path, keyword: "false", message: "no value is allowed here" });
return;
}
if (!isSchemaObject(node)) return;
const b = this.c.r.baseOf.get(node) ?? base;
if (this.ok(node, b, v)) return;
const start = out.length;
const push = (keyword: string, message: string, at = path) => {
out.push({ path: at, keyword, message });
};
if (this.c.draft === "7" && typeof node.$ref === "string") {
const target = this.c.r.resolve(node.$ref, b);
this.walk(target.node, target.base, v, path, out);
return;
}
if (node.type !== undefined) {
const types = Array.isArray(node.type) ? node.type : [node.type];
const checks = types.map((t) => (typeof t === "string" ? TYPE_CHECKS.get(t) : undefined) ?? NEVER);
if (!checks.some((check) => check(v))) {
push("type", `expected ${types.join(" or ")}, got ${typeName(v)}`);
return;
}
}
if (Array.isArray(node.enum) && !enumCheck(node.enum)(v))
push("enum", `expected one of ${JSON.stringify(node.enum)}`);
if (Object.hasOwn(node, "const") && !constCheck(node.const)(v))
push("const", `expected ${JSON.stringify(node.const)}`);
if (typeof v === "string" && node.type !== "bytes") this.walkString(node, v, push);
if (v instanceof Uint8Array && node.type === "bytes") this.walkBytes(node, v, push);
if (typeof v === "number") this.walkNumber(node, v, push);
if (isObject(v)) this.walkObject(node, b, v, path, out, push);
if (Array.isArray(v)) this.walkArray(node, b, v, path, out, push);
if (Array.isArray(node.allOf)) for (const s of node.allOf) this.walk(s, b, v, path, out);
if (Array.isArray(node.anyOf)) this.walkUnion(node.anyOf, b, v, path, out, false);
if (Array.isArray(node.oneOf)) this.walkUnion(node.oneOf, b, v, path, out, true);
if (node.not !== undefined && this.ok(node.not, b, v)) push("not", "must not match the schema in not");
if (node.if !== undefined) {
const branch = this.ok(node.if, b, v) ? node.then : node.else;
if (branch !== undefined) this.walk(branch, b, v, path, out);
}
if (typeof node.$ref === "string") {
const target = this.c.r.resolve(node.$ref, b);
this.walk(target.node, target.base, v, path, out);
}
if (out.length === start && (node.unevaluatedProperties !== undefined || node.unevaluatedItems !== undefined)) {
this.walkUnevaluated(node, b, v, path, out);
}
if (out.length === start && typeof node.$dynamicRef === "string") {
push("$dynamicRef", "does not match the dynamically referenced schema");
}
}
private walkString(node: SchemaObject, v: string, push: Push): void {
const n = codePoints(v);
if (typeof node.minLength === "number" && n < node.minLength) {
push("minLength", `expected at least ${node.minLength} characters`);
}
if (typeof node.maxLength === "number" && n > node.maxLength) {
push("maxLength", `expected at most ${node.maxLength} characters`);
}
if (typeof node.pattern === "string" && !sharedRegex(node.pattern).test(v)) {
push("pattern", `expected to match ${node.pattern}`);
}
const formats = this.c.formats;
const format =
formats !== undefined && typeof node.format === "string" && Object.hasOwn(formats, node.format)
? formats[node.format]
: undefined;
if (format !== undefined && !format(v)) push("format", `expected format ${node.format}`);
}
private walkBytes(node: SchemaObject, v: Uint8Array, push: Push): void {
if (typeof node.minLength === "number" && v.length < node.minLength) {
push("minLength", `expected at least ${node.minLength} bytes`);
}
if (typeof node.maxLength === "number" && v.length > node.maxLength) {
push("maxLength", `expected at most ${node.maxLength} bytes`);
}
}
private walkNumber(node: SchemaObject, v: number, push: Push): void {
if (typeof node.minimum === "number" && v < node.minimum) push("minimum", `expected >= ${node.minimum}`);
if (typeof node.maximum === "number" && v > node.maximum) push("maximum", `expected <= ${node.maximum}`);
if (typeof node.exclusiveMinimum === "number" && v <= node.exclusiveMinimum) {
push("exclusiveMinimum", `expected > ${node.exclusiveMinimum}`);
}
if (typeof node.exclusiveMaximum === "number" && v >= node.exclusiveMaximum) {
push("exclusiveMaximum", `expected < ${node.exclusiveMaximum}`);
}
if (typeof node.multipleOf === "number" && !isMultiple(v, node.multipleOf)) {
push("multipleOf", `expected a multiple of ${node.multipleOf}`);
}
}
private walkObject(
node: SchemaObject,
b: string,
v: Record<string, unknown>,
path: string,
out: ValidationError[],
push: Push,
): void {
const properties = isSchemaObject(node.properties) ? node.properties : {};
const required = Array.isArray(node.required) ? node.required : [];
for (const k of required) {
if (typeof k === "string" && (!Object.hasOwn(v, k) || v[k] === undefined))
push("required", "missing", pointer(path, k));
}
for (const k of Object.keys(properties)) {
if (Object.hasOwn(v, k) && v[k] !== undefined) this.walk(properties[k], b, v[k], pointer(path, k), out);
}
const patterns = isSchemaObject(node.patternProperties) ? Object.entries(node.patternProperties) : [];
for (const k of Object.keys(v)) {
let matched = Object.hasOwn(properties, k);
for (const [p, s] of patterns) {
if (sharedRegex(p).test(k)) {
matched = true;
this.walk(s, b, v[k], pointer(path, k), out);
}
}
if (!matched && node.additionalProperties !== undefined) {
if (node.additionalProperties === false) push("additionalProperties", "unknown field", pointer(path, k));
else this.walk(node.additionalProperties, b, v[k], pointer(path, k), out);
}
if (node.propertyNames !== undefined && !this.ok(node.propertyNames, b, k)) {
push("propertyNames", "invalid property name", pointer(path, k));
}
}
const count = Object.keys(v).length;
if (typeof node.minProperties === "number" && count < node.minProperties) {
push("minProperties", `expected at least ${node.minProperties} properties`);
}
if (typeof node.maxProperties === "number" && count > node.maxProperties) {
push("maxProperties", `expected at most ${node.maxProperties} properties`);
}
const dependencies = {
...(this.c.draft === "7" || !isSchemaObject(node.dependentRequired) ? {} : node.dependentRequired),
...(isSchemaObject(node.dependencies) ? node.dependencies : {}),
};
for (const [k, d] of Object.entries(dependencies)) {
if (!Object.hasOwn(v, k)) continue;
if (!Array.isArray(d)) this.walk(d, b, v, path, out);
else
for (const r of d)
if (!Object.hasOwn(v, r)) push("dependentRequired", `required when ${k} is present`, pointer(path, r));
}
if (this.c.draft !== "7" && isSchemaObject(node.dependentSchemas)) {
for (const [k, s] of Object.entries(node.dependentSchemas))
if (Object.hasOwn(v, k)) this.walk(s, b, v, path, out);
}
}
private walkArray(
node: SchemaObject,
b: string,
v: unknown[],
path: string,
out: ValidationError[],
push: Push,
): void {
let prefix: readonly unknown[] = [];
let rest: unknown;
if (Array.isArray(node.items)) {
prefix = node.items;
rest = node.additionalItems;
} else {
if (this.c.draft !== "7" && Array.isArray(node.prefixItems)) prefix = node.prefixItems;
rest = node.items;
}
for (let i = 0; i < v.length; i++) {
const s = i < prefix.length ? prefix[i] : rest;
if (s !== undefined) this.walk(s, b, v[i], `${path}/${i}`, out);
}
if (typeof node.minItems === "number" && v.length < node.minItems) {
push("minItems", `expected at least ${node.minItems} items`);
}
if (typeof node.maxItems === "number" && v.length > node.maxItems) {
push("maxItems", `expected at most ${node.maxItems} items`);
}
if (node.uniqueItems === true && !uniqueCheck(v)) push("uniqueItems", "expected unique items");
if (node.contains !== undefined) {
const draft7 = this.c.draft === "7";
const min = draft7 || typeof node.minContains !== "number" ? 1 : node.minContains;
const max = draft7 || typeof node.maxContains !== "number" ? Number.POSITIVE_INFINITY : node.maxContains;
if (!containsCheck(this.c.check(node.contains, b), min, max)(v)) {
push("contains", `expected between ${min} and ${max} matching items`);
}
}
}
private walkUnion(
branches: readonly unknown[],
b: string,
v: unknown,
path: string,
out: ValidationError[],
exactlyOne: boolean,
): void {
const matches = branches.filter((s) => this.ok(s, b, v)).length;
if (matches === 1 || (!exactlyOne && matches > 0)) return;
const keyword = exactlyOne ? "oneOf" : "anyOf";
if (matches > 1) {
out.push({ path, keyword, message: "matches more than one alternative" });
return;
}
// A tagged union reports the branch its tag selects; otherwise the branch whose first error is deepest.
const tag = this.tagOf(branches, b);
if (tag !== undefined && isObject(v)) {
const i = tag.values.indexOf(v[tag.key]);
if (i < 0)
out.push({
path: pointer(path, tag.key),
keyword,
message: `expected one of ${JSON.stringify(tag.values)}`,
});
else this.walk(branches[i], b, v, path, out);
return;
}
let best: ValidationError[] | undefined;
for (const s of branches) {
const errors: ValidationError[] = [];
this.walk(s, b, v, path, errors);
if (errors.length > 0 && (best === undefined || errors[0].path.length > best[0].path.length)) best = errors;
}
out.push(...(best ?? [{ path, keyword, message: "matches no alternative" }]));
}
private tagOf(branches: readonly unknown[], b: string): { key: string; values: unknown[] } | undefined {
const resolved = branches.map((s) =>
isSchemaObject(s) && typeof s.$ref === "string" ? this.c.r.resolve(s.$ref, b).node : s,
);
const first = resolved[0];
if (!isSchemaObject(first) || !isSchemaObject(first.properties)) return undefined;
for (const key of Object.keys(first.properties)) {
const values = resolved.map((s) => {
const tag = isSchemaObject(s) && isSchemaObject(s.properties) ? s.properties[key] : undefined;
return isSchemaObject(tag) ? tag.const : undefined;
});
const primitive = values.every((x) => x !== undefined && (typeof x !== "object" || x === null));
if (primitive && new Set(values).size === values.length) return { key, values };
}
return undefined;
}
private walkUnevaluated(node: SchemaObject, b: string, v: unknown, path: string, out: ValidationError[]): void {
const { unevaluatedProperties, unevaluatedItems, ...rest } = node;
const a = newAnnotations();
this.c.annotating(rest, b)(v, a);
if (isObject(v) && unevaluatedProperties !== undefined && !a.allProps) {
for (const k of Object.keys(v)) {
if (a.props?.has(k)) continue;
if (unevaluatedProperties === false) {
out.push({ path: pointer(path, k), keyword: "unevaluatedProperties", message: "unknown field" });
} else this.walk(unevaluatedProperties, b, v[k], pointer(path, k), out);
}
}
if (Array.isArray(v) && unevaluatedItems !== undefined && !a.allItems) {
for (let i = a.items; i < v.length; i++) {
if (a.indexes?.has(i)) continue;
if (unevaluatedItems === false) {
out.push({ path: `${path}/${i}`, keyword: "unevaluatedItems", message: "unexpected item" });
} else this.walk(unevaluatedItems, b, v[i], `${path}/${i}`, out);
}
}
}
}
type Push = (keyword: string, message: string, at?: string) => void;
function pointer(path: string, key: string | number): string {
return `${path}/${String(key).replace(/~/g, "~0").replace(/\//g, "~1")}`;
}
function typeName(v: unknown): string {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
if (v instanceof Uint8Array) return "bytes";
if (typeof v === "number" && Number.isInteger(v)) return "integer";
return typeof v;
}

View file

@ -0,0 +1,46 @@
// The format checks of JSON Schema 2020-12, for compile()'s `formats` option. An optional entry point: schemas
// compiled without it treat `format` as an annotation and do not bundle these checks.
import type { FormatCheck } from "../types.ts";
import { IsDate } from "./typebox/date.ts";
import { IsDateTime } from "./typebox/date_time.ts";
import { IsDuration } from "./typebox/duration.ts";
import { IsEmail } from "./typebox/email.ts";
import { IsHostname } from "./typebox/hostname.ts";
import { IsIdnEmail } from "./typebox/idn_email.ts";
import { IsIdnHostname } from "./typebox/idn_hostname.ts";
import { IsIPv4 } from "./typebox/ipv4.ts";
import { IsIPv6 } from "./typebox/ipv6.ts";
import { IsIri } from "./typebox/iri.ts";
import { IsIriReference } from "./typebox/iri_reference.ts";
import { IsJsonPointer } from "./typebox/json_pointer.ts";
import { IsRegex } from "./typebox/regex.ts";
import { IsRelativeJsonPointer } from "./typebox/relative_json_pointer.ts";
import { IsTime } from "./typebox/time.ts";
import { IsUri } from "./typebox/uri.ts";
import { IsUriReference } from "./typebox/uri_reference.ts";
import { IsUriTemplate } from "./typebox/uri_template.ts";
import { IsUuid } from "./typebox/uuid.ts";
/** Every format JSON Schema 2020-12 defines, by name. The checks are adapted from TypeBox 1.3.27 (MIT). */
export const formats: Readonly<Record<string, FormatCheck>> = Object.freeze({
date: IsDate,
"date-time": IsDateTime,
duration: IsDuration,
email: IsEmail,
hostname: IsHostname,
"idn-email": IsIdnEmail,
"idn-hostname": IsIdnHostname,
ipv4: IsIPv4,
ipv6: IsIPv6,
iri: IsIri,
"iri-reference": IsIriReference,
"json-pointer": IsJsonPointer,
regex: IsRegex,
"relative-json-pointer": IsRelativeJsonPointer,
time: IsTime,
uri: IsUri,
"uri-reference": IsUriReference,
"uri-template": IsUriTemplate,
uuid: IsUuid,
});

View file

@ -0,0 +1,47 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
function IsLeapYear(year: number): boolean {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
/**
* Returns true if the value is a ISO8601 Date component string
* @description https://datatracker.ietf.org/doc/html/rfc3339
* @example `2020-12-12`
*/
export function IsDate(value: string): boolean {
const matches: string[] | null = DATE.exec(value);
if (!matches) return false;
const year: number = +matches[1];
const month: number = +matches[2];
const day: number = +matches[3];
return month >= 1 && month <= 12 && day >= 1 && day <= (month === 2 && IsLeapYear(year) ? 29 : DAYS[month]);
}

View file

@ -0,0 +1,40 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import { IsDate } from "./date.ts";
import { IsTime } from "./time.ts";
/**
* Returns true if the value is a ISO8601 DateTime string
* @specification https://datatracker.ietf.org/doc/html/rfc3339
* @example `2020-12-12T20:20:40+00:00`
*/
export function IsDateTime(value: string): boolean {
const dateTime: string[] = value.split(/T/i);
return dateTime.length === 2 && IsDate(dateTime[0]) && IsTime(dateTime[1]);
}

View file

@ -0,0 +1,38 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const Duration =
/^P((\d+Y(\d+M(\d+D)?)?|\d+M(\d+D)?|\d+D)(T(\d+H(\d+M(\d+S)?)?|\d+M(\d+S)?|\d+S))?|T(\d+H(\d+M(\d+S)?)?|\d+M(\d+S)?|\d+S)|\d+W)$/;
/**
* Returns true if the value is a valid ISO-8601 duration.
* @specification https://tools.ietf.org/html/rfc3339
*/
export function IsDuration(value: string): boolean {
return Duration.test(value);
}

View file

@ -0,0 +1,38 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const Email =
/^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[^"\\]|\\[\x20-\x7e])*")@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*|\[(?:IPv6:[a-f0-9:]+|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})\])$/i;
/**
* Returns true if the value is an Email
* @specification https://datatracker.ietf.org/doc/html/rfc5322
*/
export function IsEmail(value: string): boolean {
return Email.test(value);
}

View file

@ -0,0 +1,39 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import * as Idna from "./idna/index.ts";
/**
* Returns true if the value is a valid hostname.
* @specification https://tools.ietf.org/html/rfc1123
* @specification https://tools.ietf.org/html/rfc5891
* @specification https://tools.ietf.org/html/rfc5892
*/
export function IsHostname(value: string): boolean {
return Idna.IsHostname(value);
}

View file

@ -0,0 +1,38 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const IdnEmail =
/^(?:[A-Za-z0-9!#$%&'*+/=?^_`{|}~\u{0080}-\u{10FFFF}-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~\u{0080}-\u{10FFFF}-]+)*|"(?:[^"\\]|\\.)*")@[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,62})(?<!-)(?:\.[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,62})(?<!-))*$/iu;
/**
* Returns true if the value is an IdnEmail
* @specification https://datatracker.ietf.org/doc/html/rfc4952
*/
export function IsIdnEmail(value: string): boolean {
return IdnEmail.test(value.normalize("NFC"));
}

View file

@ -0,0 +1,40 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import * as Idna from "./idna/index.ts";
/**
* Returns true if the value is a valid internationalized (IDN) hostname.
* @specification https://tools.ietf.org/html/rfc3490
* @specification https://tools.ietf.org/html/rfc5891
* @specification https://tools.ietf.org/html/rfc5892
* @specification https://tools.ietf.org/html/rfc5893
*/
export function IsIdnHostname(value: string): boolean {
return Idna.IsIdnHostname(value);
}

View file

@ -0,0 +1,127 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
// deno-fmt-ignore-file
import * as Pattern from "../pattern/pattern.ts";
import * as FormatPuny from "./puny.ts";
// ------------------------------------------------------------------
// LOCAL REGEX: Allowed Bidi classes per direction (RFC 5893)
// ------------------------------------------------------------------
const RE_RTL_ALLOWED = /^(?:R|AL|AN|EN|ES|CS|ET|ON|BN|NSM)$/;
const RE_LTR_ALLOWED = /^(?:L|EN|ES|CS|ET|ON|BN|NSM)$/;
const RE_RTL_CLASSES = /^(?:R|AL|AN)$/;
// ------------------------------------------------------------------
// HasBidiChars
//
// Decodes a punycode label to its Unicode form (if it is one) and
// checks whether the result is an RTL label per RFC 5893 §1.4.
// ------------------------------------------------------------------
export function HasBidiChars(value: string): boolean {
if (FormatPuny.IsAcePrefixed(value)) {
try {
return HasRightToLeftCharacters(FormatPuny.Decode(value.slice(4).toLowerCase()));
} catch {
return false;
}
}
return HasRightToLeftCharacters(value);
}
// ------------------------------------------------------------------
// GetBidiClass
// ------------------------------------------------------------------
function GetBidiClass(codePoint: number): string {
const char = String.fromCodePoint(codePoint);
return Pattern.RE_EUROPEAN_NUMBER.test(char)
? "EN"
: Pattern.RE_ARABIC_INDIC_DIGIT.test(char)
? "AN"
: // Pattern.RE_EUROPEAN_SEPARATOR.test(char) ? 'ES' : // (no-spec-coverage)
// Pattern.RE_COMMON_SEPARATOR.test(char) ? 'CS' : // (no-spec-coverage)
Pattern.RE_MARK_NONSPACING.test(char)
? "NSM"
: Pattern.RE_SCRIPT_HEBREW.test(char)
? "R"
: Pattern.RE_SCRIPT_ARABIC_LETTER.test(char)
? "AL"
: Pattern.RE_LETTER.test(char)
? "L"
: "ON";
}
// ------------------------------------------------------------------
// HasRightToLeftCharacters
//
// Per RFC 5893 §1.4: "An RTL label is a label that contains at least
// one character of type R, AL, or AN." Used to determine whether a
// domain name is a "Bidi domain name" (RFC 5893 §1.4), in which case
// the Bidi Rule applies to every label in the domain.
// ------------------------------------------------------------------
export function HasRightToLeftCharacters(value: string): boolean {
for (const ch of value) if (RE_RTL_CLASSES.test(GetBidiClass(ch.codePointAt(0)!))) return true;
return false;
}
// ------------------------------------------------------------------
// SatisfiesBidiRule
// ------------------------------------------------------------------
export function SatisfiesBidiRule(value: string): boolean {
let isRtl = false;
let allowed = RE_LTR_ALLOWED;
let sawEN = false;
let sawAN = false;
let isFirst = true;
// let lastClass: string | null = null // (no-spec-coverage)
for (const ch of value) {
const bidiClass = GetBidiClass(ch.codePointAt(0)!);
if (isFirst) {
// 1. The first character must be a character with Bidi property L, R, or AL.
if (bidiClass !== "L" && bidiClass !== "R" && bidiClass !== "AL") return false;
isRtl = bidiClass === "R" || bidiClass === "AL";
allowed = isRtl ? RE_RTL_ALLOWED : RE_LTR_ALLOWED;
isFirst = false;
}
// 2 / 5. Only characters in the allowed set for this direction may appear.
if (!allowed.test(bidiClass)) return false;
if (bidiClass === "EN") sawEN = true;
else if (bidiClass === "AN") sawAN = true;
// if (bidiClass !== 'NSM') lastClass = bidiClass // (no-spec-coverage)
}
if (isRtl && sawEN && sawAN) return false; // spec coverage path
// (no-spec-coverage)
// if (isRtl) {
// // 3. The end of the label must be R, AL, EN, or AN (ignoring trailing NSM).
// // if (lastClass !== 'R' && lastClass !== 'AL' && lastClass !== 'EN' && lastClass !== 'AN') return false
// // 4. If an EN is present, no AN may be present, and vice versa.
// if (sawEN && sawAN) return false
// } else {
// // 6. The end of the label must be L or EN (ignoring trailing NSM).
// // if (lastClass !== 'L' && lastClass !== 'EN') return false
// }
return true;
}

View file

@ -0,0 +1,167 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import * as Pattern from "../pattern/pattern.ts";
// ------------------------------------------------------------------
// PunyCode (RFC 3492)
// ------------------------------------------------------------------
const PUNYCODE_BASE = 36;
const PUNYCODE_TMIN = 1;
const PUNYCODE_TMAX = 26;
const PUNYCODE_SKEW = 38;
const PUNYCODE_DAMP = 700;
const PUNYCODE_INITIAL_BIAS = 72;
const PUNYCODE_INITIAL_N = 128;
// ------------------------------------------------------------------
// ThrowDontCare
// ------------------------------------------------------------------
function ThrowDontCare(): never {
throw null;
}
// ------------------------------------------------------------------
// IsAcePrefixed
// ------------------------------------------------------------------
export function IsAcePrefixed(value: string): boolean {
return value.toLowerCase().startsWith("xn--");
}
// ------------------------------------------------------------------
// Adapt
// ------------------------------------------------------------------
function Adapt(delta: number, numPoints: number, firstTime: boolean): number {
delta = firstTime ? Math.floor(delta / PUNYCODE_DAMP) : delta >> 1;
delta += Math.floor(delta / numPoints);
let k = 0;
while (delta > ((PUNYCODE_BASE - PUNYCODE_TMIN) * PUNYCODE_TMAX) >> 1) {
delta = Math.floor(delta / (PUNYCODE_BASE - PUNYCODE_TMIN));
k += PUNYCODE_BASE;
}
return k + Math.floor(((PUNYCODE_BASE - PUNYCODE_TMIN + 1) * delta) / (delta + PUNYCODE_SKEW));
}
// ------------------------------------------------------------------
// Decode
// ------------------------------------------------------------------
export function Decode(value: string): string {
const output: number[] = [];
let n = PUNYCODE_INITIAL_N;
let i = 0;
let bias = PUNYCODE_INITIAL_BIAS;
const delimIdx = value.lastIndexOf("-");
if (delimIdx > 0) {
for (let j = 0; j < delimIdx; j++) {
const cp = value.charCodeAt(j);
if (cp >= 128) ThrowDontCare(); // throw new Error('Invalid punycode: non-basic before delimiter')
output.push(cp);
}
}
let inIdx = delimIdx < 0 ? 0 : delimIdx + 1;
while (inIdx < value.length) {
const oldi = i;
let w = 1;
let k = PUNYCODE_BASE;
while (true) {
if (inIdx >= value.length) ThrowDontCare(); // throw new Error('Invalid punycode: unexpected end of input')
const ch = value.charCodeAt(inIdx++);
let digit: number;
if (ch >= 0x61 && ch <= 0x7a)
digit = ch - 0x61; // a-z => 0-25
else if (ch >= 0x30 && ch <= 0x39)
digit = ch - 0x30 + 26; // 0-9 => 26-35
// else if (ch >= 0x41 && ch <= 0x5a) digit = ch - 0x41 // A-Z => 0-25 // (no-spec-coverage)
else ThrowDontCare(); // throw new Error('Invalid punycode: bad digit character')
i += digit * w;
const t = k <= bias ? PUNYCODE_TMIN : k >= bias + PUNYCODE_TMAX ? PUNYCODE_TMAX : k - bias;
if (digit < t) break;
w *= PUNYCODE_BASE - t;
k += PUNYCODE_BASE;
}
const outLen = output.length + 1;
bias = Adapt(i - oldi, outLen, oldi === 0);
n += Math.floor(i / outLen);
i %= outLen;
output.splice(i, 0, n);
i++;
}
return String.fromCodePoint(...output);
}
// ------------------------------------------------------------------
// DigitToChar
// ------------------------------------------------------------------
function DigitToChar(digit: number): string {
return digit < 26 ? String.fromCharCode(digit + 0x61) : String.fromCharCode(digit - 26 + 0x30);
}
// ------------------------------------------------------------------
// Encode
//
// Encodes a Unicode string into its Punycode payload (the part that
// follows the 'xn--' ACE prefix). Used to compute the true A-label
// length of a U-label, since the 63-octet label length limit (RFC
// 5891 §4.2.4) applies to the encoded form, not the code point count.
// ------------------------------------------------------------------
const RE_REPLACE_NON_ASCII = new RegExp(Pattern.RE_NON_ASCII.source, "gu");
export function Encode(input: string): string {
// Extract basic ASCII characters and set initial output array state
const basic = input.replace(RE_REPLACE_NON_ASCII, "");
const basicLength = basic.length;
const result = basicLength > 0 ? [basic, "-"] : [];
// Extract unicode codepoints
const codePoints = Array.from(input, (char) => char.codePointAt(0)!);
let n = PUNYCODE_INITIAL_N;
let delta = 0;
let bias = PUNYCODE_INITIAL_BIAS;
let handledCPCount = basicLength;
while (handledCPCount < codePoints.length) {
let m = Infinity;
for (const cp of codePoints) {
if (cp >= n && cp < m) m = cp;
}
delta += (m - n) * (handledCPCount + 1);
n = m;
for (const cp of codePoints) {
if (cp < n) delta++;
if (cp === n) {
let q = delta;
for (let k = PUNYCODE_BASE; ; k += PUNYCODE_BASE) {
const t = k <= bias ? PUNYCODE_TMIN : k >= bias + PUNYCODE_TMAX ? PUNYCODE_TMAX : k - bias;
if (q < t) break;
const digit = t + ((q - t) % (PUNYCODE_BASE - t));
result.push(DigitToChar(digit));
q = Math.floor((q - t) / (PUNYCODE_BASE - t));
}
result.push(DigitToChar(q));
bias = Adapt(delta, handledCPCount + 1, handledCPCount === basicLength);
delta = 0;
handledCPCount++;
}
}
delta++;
n++;
}
return result.join("");
}

View file

@ -0,0 +1,42 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import * as LabelAscii from "./label/ascii.ts";
import * as LabelPuny from "./label/puny.ts";
function IsValidLabelLength(value: string): boolean {
return value.length > 0 && value.length <= 63;
}
function IsLabel(value: string): boolean {
return IsValidLabelLength(value) && (LabelPuny.IsPunyLabel(value) || LabelAscii.IsAsciiLabel(value));
}
export function IsHostname(value: string): boolean {
if (value.length === 0 || value.length > 253) return false;
if (value.charCodeAt(value.length - 1) === 46) return false;
return value.split(".").every((label) => IsLabel(label));
}

View file

@ -0,0 +1,72 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import * as FormatBidi from "./format/bidi.ts";
import * as LabelPuny from "./label/puny.ts";
import * as LabelUnicode from "./label/unicode.ts";
// ------------------------------------------------------------------
// IsLabel
// ------------------------------------------------------------------
function IsValidLabelLength(value: string): boolean {
return value.length > 0 && value.length <= 63;
}
function IsLabel(value: string): boolean {
return IsValidLabelLength(value) && (LabelPuny.IsPunyLabel(value) || LabelUnicode.IsUnicodeLabel(value));
}
// ------------------------------------------------------------------
// NormalizeHostname
//
// Normalizes a hostname for label validation. Maps fullwidth
// codepoints to their ASCII equivalent (e.g. 'a' U+FF41 maps to
// 'a'), then normalizes for NFC such that equivalent codepoint
// sequences can be uniformly compared. It also removes codepoints
// that IDNA mapping ignores, and converts full stop variants to
// ASCII '.' so the hostname can be split into labels.
// ------------------------------------------------------------------
function NormalizeHostname(value: string): string {
return (
value
.replace(/[\uff01-\uff5e]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0)) // RE_FULLWIDTH_MAPPING
.normalize("NFC")
// biome-ignore lint/suspicious/noMisleadingCharacterClass: a set of single code points to remove (u flag), by design.
.replace(/[\u00ad\u034f\u180b-\u180d\u200b\ufe00-\ufe0f\u{e0100}-\u{e01ef}]/gu, "") // RE_IGNORED_MAPPING
.replace(/[\u002E\u3002\uFF0E\uFF61]/g, ".")
); // RE_FULL_STOP_MAPPING
}
// ------------------------------------------------------------------
// IsIdnHostname
// ------------------------------------------------------------------
export function IsIdnHostname(value: string): boolean {
if (value.length === 0 || value.includes(" ")) return false;
const normalized = NormalizeHostname(value);
if (normalized.length > 253) return false;
const labels = normalized.split(".");
const hasBidiChars = labels.some((label) => FormatBidi.HasBidiChars(label));
return labels.every((label) => IsLabel(label) && (!hasBidiChars || FormatBidi.SatisfiesBidiRule(label)));
}

View file

@ -0,0 +1,30 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
export * from "./hostname.ts";
export * from "./idn-hostname.ts";

View file

@ -0,0 +1,40 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import * as Pattern from "../pattern/pattern.ts";
// -------------------------------------------------------------------
// Validates an ASCII LDH label per RFC 5891 §4.2.3.1
// -------------------------------------------------------------------
export function IsAsciiLabel(value: string): boolean {
return (
Pattern.RE_RULE_HYPHEN_PLACEMENT.test(value) &&
Pattern.RE_RULE_NOT_RESERVED_ACE.test(value) &&
Pattern.RE_ASCII_LDH.test(value)
);
}

View file

@ -0,0 +1,53 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import * as FormatPuny from "../format/puny.ts";
import * as Pattern from "../pattern/pattern.ts";
import * as LabelUnicode from "./unicode.ts";
// ------------------------------------------------------------------
// IsPunyLabel
// ------------------------------------------------------------------
export function IsPunyLabel(value: string): boolean {
if (!FormatPuny.IsAcePrefixed(value)) return false;
try {
const body = value.slice(4).toLowerCase();
// A payload consisting of only a hyphen, or starting with the delimiter
// (like "-9uc"), has 0 basic ASCII characters preceding it and is malformed.
if (body.lastIndexOf("-") === 0) return false;
const decoded = FormatPuny.Decode(body);
// RFC 5890 §2.3.2.1 - a U-label must contain at least one non-ASCII
// character. Code-unit level check: any non-ASCII codepoint has at least
// one UTF-16 unit >= 0x80 (surrogate halves included), so no need to
// decode codepoints or allocate a character array to answer this.
if (!Pattern.RE_NON_ASCII.test(decoded)) return false;
return LabelUnicode.IsUnicodeLabel(decoded);
} catch {
return false;
}
}

View file

@ -0,0 +1,122 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import * as FormatBidi from "../format/bidi.ts";
import * as FormatPuny from "../format/puny.ts";
import * as Pattern from "../pattern/pattern.ts";
// ------------------------------------------------------------------
// ExceedsMaxALabelLength
//
// RFC 5890 §2.3.2.1: an A-label (the ACE-encoded form of a label,
// "xn--" + punycode) must not exceed 63 octets. Only labels
// containing non-ASCII characters need checking - a pure-ASCII label
// has no "xn--" prefix and is used as-is, so its punycode-encoded
// length is irrelevant. The ASCII check is a cheap short-circuit that
// avoids running the punycode encoder on labels that don't need it.
// ------------------------------------------------------------------
function ExceedsMaxALabelLength(value: string): boolean {
return Pattern.RE_NON_ASCII.test(value) && FormatPuny.Encode(value).length + 4 > 63;
}
// ------------------------------------------------------------------
// HasInvalidHyphens
//
// RFC 5891 §4.2.3.2: label must not start or end with a hyphen, and
// must not have hyphens in the 3rd and 4th codepoint positions (the
// ACE prefix reservation, e.g. "xn--"). Operates on `chars` (the
// per-codepoint string array) rather than raw string indices so
// supplementary-plane characters earlier in the label don't throw
// off the codepoint-position count.
// ------------------------------------------------------------------
function HasInvalidHyphens(chars: string[]): boolean {
if (chars[0] === "-" || chars[chars.length - 1] === "-") return true;
return chars.slice(2).join("").startsWith("--");
}
// ------------------------------------------------------------------
// IsUnicodeLabel
// ------------------------------------------------------------------
export function IsUnicodeLabel(value: string): boolean {
// RFC 5890 §2.3.2.1: Ensure Max Length
if (ExceedsMaxALabelLength(value)) return false;
// RFC 5893 - Bidi Rule (applies whenever this label itself is RTL)
if (FormatBidi.HasRightToLeftCharacters(value) && !FormatBidi.SatisfiesBidiRule(value)) return false;
const chars = [...value];
const codePoints = chars.map((c) => c.codePointAt(0)!);
const length = codePoints.length;
// RFC 5891 §4.2.3.2: Hyphen rules
if (HasInvalidHyphens(chars)) return false;
// RFC 5891 §4.2.3.2 - Must not begin with a combining mark
if (Pattern.RE_COMBINING_MARK.test(chars[0])) return false;
let hasJapanese = false;
// let hasArabicIndic = false // (no-spec-coverage)
// let hasExtendedArabicIndic = false // (no-spec-coverage)
for (let i = 0; i < length; i++) {
const codePoint = codePoints[i];
const char = chars[i];
// 1. DISALLOWED exceptions
if (Pattern.RE_RFC5892_DISALLOWED.test(char)) return false;
// 1b. General category (PVALID) enforcement
if (!Pattern.RE_PERMITTED_CATEGORY.test(char)) return false;
// 2. Collect Flags
if (Pattern.RE_SCRIPT_JAPANESE.test(char)) hasJapanese = true;
// if (Pattern.RE_ARABIC_INDIC_DIGIT.test(char)) hasArabicIndic = true // (no-spec-coverage)
// if (Pattern.RE_EXT_ARABIC_INDIC_DIGIT.test(char)) hasExtendedArabicIndic = true // (no-spec-coverage)
// 3. CONTEXTO / CONTEXTJ Neighbor Rules
const prev = codePoints[i - 1],
next = codePoints[i + 1];
switch (codePoint) {
case 0x00b7:
if (prev !== 0x006c || next !== 0x006c) return false;
break; // MIDDLE DOT (Catalan)
case 0x0375:
if (!next || !Pattern.RE_SCRIPT_GREEK.test(chars[i + 1])) return false;
break; // Greek KERAIA
case 0x05f3:
case 0x05f4:
if (!prev || !Pattern.RE_SCRIPT_HEBREW.test(chars[i - 1])) return false;
break; // Hebrew GERESH
case 0x200c: // ZWNJ - RFC 5892 Appendix A.1
// Note: Full validation requires a Unicode joining type table. We reject
// only when preceded by ASCII (U+0000-U+007F), which can never satisfy
// the Virama or cursive-joining rules.
if (!prev || (prev < 0x0080 && !Pattern.RE_VIRAMA.test(chars[i - 1]))) return false;
break;
case 0x200d: // ZWJ: RFC 5892 Appendix A.2
if (!prev || !Pattern.RE_VIRAMA.test(chars[i - 1])) return false;
break;
case 0x30fb: // Checked at end via hasJapanese
break; // KATAKANA MIDDLE DOT
}
}
// 4. Global Context Validations (Post-loop)
// RFC 5892 Appendix A.7 - Katakana Middle Dot requirement
if (value.includes("\u30fb") && !hasJapanese) return false;
// RFC 5892 Appendix A.8/A.9 - Mixing Arabic Digits
// if (hasArabicIndic && hasExtendedArabicIndic) return false // (no-spec-coverage)
return true;
}

View file

@ -0,0 +1,116 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
// deno-fmt-ignore-file
// -------------------------------------------------------------------
// Validation Rules
// -------------------------------------------------------------------
export const RE_RULE_HYPHEN_PLACEMENT = /^(?!-).*(?<!-)$/;
export const RE_RULE_NOT_RESERVED_ACE = /^(?!..--)/;
export const RE_ASCII_LDH = /^[a-zA-Z0-9-]*$/;
// -------------------------------------------------------------------
// ASCII & Encoding
// -------------------------------------------------------------------
export const RE_ASCII = /^\p{ASCII}*$/u;
export const RE_NON_ASCII = /[^\p{ASCII}]/u;
// -------------------------------------------------------------------
// Structural & Unicode Controls
// -------------------------------------------------------------------
export const RE_HIGH_SURROGATE = /[\u{d800}-\u{dbff}]/u;
export const RE_REGIONAL_INDICATOR = /[\u{1f1e6}-\u{1f1ff}]/u;
export const RE_VARIATION_SELECTOR = /[\u{fe00}-\u{fe0f}]/u;
export const RE_ZERO_WIDTH_JOINER = /\u{200d}/u;
// -------------------------------------------------------------------
// Numbers & Digits
// -------------------------------------------------------------------
export const RE_ASCII_DIGIT = /[0-9]/;
export const RE_ARABIC_INDIC_DIGIT = /[\u{0660}-\u{0669}]/u;
export const RE_EXT_ARABIC_INDIC_DIGIT = /[\u{06f0}-\u{06f9}]/u;
// -------------------------------------------------------------------
// Separators
// -------------------------------------------------------------------
export const RE_COMMON_SEPARATOR = /[\u{002e}\u{002c}\u{003a}\u{002f}]/u;
export const RE_EUROPEAN_SEPARATOR = /[\u{002d}\u{002b}]/u;
// -------------------------------------------------------------------
// General Categories
// -------------------------------------------------------------------
export const RE_MARK_NONSPACING = /\p{Mn}/u;
export const RE_MARK_SPACING_COMBINING = /\p{Mc}/u;
export const RE_MARK_ENCLOSING = /\p{Me}/u;
export const RE_COMBINING_MARK = /[\p{Mn}\p{Mc}\p{Me}]/u;
export const RE_LETTER = /\p{L}/u;
export const RE_NUMBER_DECIMAL = /\p{Nd}/u;
// -------------------------------------------------------------------
// Scripts
// -------------------------------------------------------------------
export const RE_SCRIPT_GREEK = /\p{Script=Greek}/u;
export const RE_SCRIPT_HEBREW = /\p{Script=Hebrew}/u;
export const RE_SCRIPT_JAPANESE = /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u;
export const RE_SCRIPT_ARABIC_LETTER = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Mandaic}]/u;
// -------------------------------------------------------------------
// RFC Specifications & Context Exceptions
// -------------------------------------------------------------------
export const RE_VIRAMA =
/[\u{094d}\u{09cd}\u{0a4d}\u{0acd}\u{0b4d}\u{0bcd}\u{0c4d}\u{0ccd}\u{0d3b}\u{0d3c}\u{0d4d}\u{0dca}\u{1b44}\u{1baa}\u{1bab}\u{a9c0}\u{11046}\u{1107f}\u{110b9}\u{11133}\u{11134}\u{111c0}\u{11235}\u{1134d}\u{11442}\u{114c2}\u{115bf}\u{1163f}\u{116b6}\u{11c3f}\u{11d44}\u{11d45}]/u;
export const RE_RFC5892_DISALLOWED =
/[\u{0640}\u{07fa}\u{302e}\u{302f}\u{3031}\u{3032}\u{3033}\u{3034}\u{3035}\u{303b}]/u;
// biome-ignore lint/suspicious/noMisleadingCharacterClass: a set of single code points (u flag), including ZWJ and ZWNJ, by design.
export const RE_CONTEXTO_EXCEPTIONS = /[\u{00b7}\u{0375}\u{05f3}\u{05f4}\u{200c}\u{200d}\u{30fb}]/u;
export const RE_PVALID_EXCEPTIONS = /[\u{00df}\u{03c2}\u{06fd}\u{06fe}\u{0f0b}\u{3007}]/u;
// -------------------------------------------------------------------
// Composite Patterns
// -------------------------------------------------------------------
export const RE_EUROPEAN_NUMBER = new RegExp(
[RE_ASCII_DIGIT, RE_EXT_ARABIC_INDIC_DIGIT].map((regexp) => regexp.source).join("|"),
"u",
);
export const RE_PERMITTED_CATEGORY = new RegExp(
[
RE_LETTER,
RE_EUROPEAN_SEPARATOR,
RE_COMMON_SEPARATOR,
RE_NUMBER_DECIMAL,
RE_MARK_NONSPACING,
RE_MARK_SPACING_COMBINING,
RE_CONTEXTO_EXCEPTIONS,
RE_PVALID_EXCEPTIONS,
]
.map((regexp) => regexp.source)
.join("|"),
"u",
);

View file

@ -0,0 +1,37 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const IPv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
/**
* Returns true if the value is a IPV4 address
* @specification http://tools.ietf.org/html/rfc2673#section-3.2
*/
export function IsIPv4(value: string): boolean {
return IPv4.test(value);
}

View file

@ -0,0 +1,38 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const IPv6 =
/^(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:)?[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)$/i;
/**
* Returns true if the value is an IPv6 address
* @specification http://tools.ietf.org/html/rfc2373#section-2.2
*/
export function IsIPv6(value: string): boolean {
return IPv6.test(value);
}

View file

@ -0,0 +1,63 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
// deno-lint-ignore-file no-control-regex
const IpvFutureMatchMaxLength = 2048;
const IpvFutureMatch = /\[[vV][0-9a-fA-F]+\.[^\]]+\]/; // Guarded By IpvFutureMatchMaxLength
const InvalidIriChars = /[\x00-\x20<>^`{|}\\]/;
const InvalidPercentEncoding = /%(?![0-9a-fA-F]{2})/;
// ------------------------------------------------------------------
// NarrowIpvFuture
//
// Substitutes an IPvFuture address with a standard IPv6 loopback
// address ([::1]). We do this because the native URL.canParse
// (WHATWG standard) rejects IPvFuture literals, which are
// otherwise valid in RFC 3987. Because regex substitution can
// be expensive on large strings, this operation is strictly
// limited to inputs under a defined length threshold.
//
// (review-optimization)
//
// ------------------------------------------------------------------
function NarrowIpvFuture(value: string): string {
return value.length < IpvFutureMatchMaxLength ? value.replace(IpvFutureMatch, "[::1]") : value;
}
/**
* Returns true if the value is a valid Internationalized Resource Identifier.
* @specification https://datatracker.ietf.org/doc/html/rfc3987
*/
export function IsIri(value: string): boolean {
// 1. Reject strings containing unencoded whitespace or illegal control characters.
if (InvalidIriChars.test(value)) return false;
// 2. Reject malformed percent-encoding triplets.
if (InvalidPercentEncoding.test(value)) return false;
// 3. Delegate to the native URL parser, patching the IPvFuture edge case beforehand.
return URL.canParse(NarrowIpvFuture(value));
}

View file

@ -0,0 +1,42 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
// deno-lint-ignore-file no-control-regex
// Rejects unencoded spaces (0x20), backslashes, control characters (0x00-0x1F, 0x7F), or malformed percent-encodings
const InvalidIriChars = /[\x00-\x20\x7F\\]|%(?![0-9a-fA-F]{2})/;
// Detects malformed scheme/authority patterns lacking a colon (e.g. "httpx//example.com")
const MalformedScheme = /^[a-zA-Z][a-zA-Z0-9+\-.]*\/\//;
/**
* Returns true if the value is an IRI reference
* @specification https://tools.ietf.org/html/rfc3987
*/
export function IsIriReference(value: string): boolean {
return !InvalidIriChars.test(value) && !MalformedScheme.test(value) && URL.canParse(value, "http://example.com");
}

View file

@ -0,0 +1,38 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const JsonPointer = /^(?:\/(?:[^~/]|~0|~1)*)*$/;
/**
* Returns true if the value is a json pointer
* @specification https://datatracker.ietf.org/doc/html/rfc6901
* @source https://github.com/ajv-validator/ajv-formats
*/
export function IsJsonPointer(value: string): boolean {
return JsonPointer.test(value);
}

View file

@ -0,0 +1,40 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
/**
* Returns true if the value is a regular expression string pattern
* @specification https://ecma-international.org/ecma-262
*/
export function IsRegex(value: string): boolean {
try {
new RegExp(value, "u");
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,38 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const RelativeJsonPointer = /^(?:0|[1-9][0-9]*)(?:#|(?:\/(?:[^~/]|~0|~1)*)*)$/;
/**
* Returns true if the value is a relative json pointer
* @specification https://datatracker.ietf.org/doc/html/rfc6901
* @source https://github.com/ajv-validator/ajv-formats
*/
export function IsRelativeJsonPointer(value: string): boolean {
return RelativeJsonPointer.test(value);
}

View file

@ -0,0 +1,56 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const TIME = /^(\d\d):(\d\d):(\d\d)(?:\.\d+)?(?:([Zz])|([+-])(\d\d):(\d\d))?$/;
/**
* Returns true if the value is an ISO time string
* @specification https://datatracker.ietf.org/doc/html/rfc3339
*/
export function IsTime(value: string, strictTimeZone: boolean = true): boolean {
const matches = TIME.exec(value);
if (!matches) return false;
// Require timezone offset or 'Z'/'z' when strictTimeZone is true
if (strictTimeZone && !matches[4] && !matches[5]) return false;
const hr = +matches[1];
const min = +matches[2];
const sec = +matches[3];
if (hr > 23 || min > 59 || sec > 60) return false;
if (matches[5]) {
const tzH = +matches[6];
const tzM = +matches[7];
if (tzH > 23 || tzM > 59) return false;
}
if (sec < 60) return true;
// Leap second handling: must normalize to 23:59:60 UTC (1439 total UTC minutes)
const tzSign = matches[5] === "-" ? -1 : 1;
const tzH = +(matches[6] || 0);
const tzM = +(matches[7] || 0);
const totalUtcMin = hr * 60 + min - tzSign * (tzH * 60 + tzM);
return ((totalUtcMin % 1440) + 1440) % 1440 === 1439;
}

View file

@ -0,0 +1,38 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const Uri =
/^[a-z][a-z0-9+\-.]*:(?:\/\/(?:(?:[-a-z0-9._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:[\da-f]{1,4}:){6}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|::(?:[\da-f]{1,4}:){5}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:[\da-f]{1,4})?::(?:[\da-f]{1,4}:){4}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,1}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){3}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,2}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){2}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,3}[\da-f]{1,4})?::[\da-f]{1,4}:(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,4}[\da-f]{1,4})?::(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,5}[\da-f]{1,4})?::[\da-f]{1,4}|(?:(?:[\da-f]{1,4}:){0,6}[\da-f]{1,4})?::)|v[0-9a-f]+\.[-a-z0-9._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)|(?:[-a-z0-9._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?(?:\?(?:[-a-z0-9._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[-a-z0-9._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
/**
* Returns true if the value is a valid Uniform Resource Identifier.
* @specification https://tools.ietf.org/html/rfc3986
*/
export function IsUri(value: string): boolean {
return Uri.test(value);
}

View file

@ -0,0 +1,38 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const UriReference =
/^(?:[a-z][a-z0-9+\-.]*:(?:\/\/(?:(?:[-a-z0-9._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:[\da-f]{1,4}:){6}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|::(?:[\da-f]{1,4}:){5}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:[\da-f]{1,4})?::(?:[\da-f]{1,4}:){4}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,1}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){3}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,2}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){2}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,3}[\da-f]{1,4})?::[\da-f]{1,4}:(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,4}[\da-f]{1,4})?::(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,5}[\da-f]{1,4})?::[\da-f]{1,4}|(?:(?:[\da-f]{1,4}:){0,6}[\da-f]{1,4})?::)|v[0-9a-f]+\.[-a-z0-9._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)|(?:[-a-z0-9._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:\/\/(?:(?:[-a-z0-9._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:[\da-f]{1,4}:){6}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|::(?:[\da-f]{1,4}:){5}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:[\da-f]{1,4})?::(?:[\da-f]{1,4}:){4}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,1}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){3}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,2}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){2}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,3}[\da-f]{1,4})?::[\da-f]{1,4}:(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,4}[\da-f]{1,4})?::(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,5}[\da-f]{1,4})?::[\da-f]{1,4}|(?:(?:[\da-f]{1,4}:){0,6}[\da-f]{1,4})?::)|v[0-9a-f]+\.[-a-z0-9._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)|(?:[-a-z0-9._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[-a-z0-9._~!$&'()*+,;=@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?)(?:\?(?:[-a-z0-9._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[-a-z0-9._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
/**
* Returns true if the value is a valid Uri Reference.
* @specification https://tools.ietf.org/html/rfc3986
*/
export function IsUriReference(value: string): boolean {
return UriReference.test(value);
}

View file

@ -0,0 +1,40 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
// deno-lint-ignore-file no-control-regex
const UriTemplate =
/^(?:(?:[^\x00-\x20"<>%\\^`{|}\x7f]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?:\.(?:[a-z0-9_]|%[0-9a-f]{2})+)*(?::[1-9]\d{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?:\.(?:[a-z0-9_]|%[0-9a-f]{2})+)*(?::[1-9]\d{0,3}|\*)?)*\})*$/i;
/**
* Returns true if the value is a valid Uri Template
* @specification https://datatracker.ietf.org/doc/html/rfc6570
*/
export function IsUriTemplate(value: string): boolean {
return UriTemplate.test(value);
}

View file

@ -0,0 +1,38 @@
/*--------------------------------------------------------------------------
TypeBox
The MIT License (MIT)
Copyright (c) 2017-2026 Haydn Paterson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
const Uuid = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
/**
* Returns true if the value is a uuid
* @specification https://www.rfc-editor.org/info/rfc4122/
* @specification https://www.rfc-editor.org/info/rfc9562/
*/
export function IsUuid(value: string): boolean {
return Uuid.test(value);
}

View file

@ -0,0 +1,31 @@
// JSON Schema validation (drafts 2020-12 and 7) without eval: compile() builds a schema into plain closures once.
// The builders return plain JSON Schema objects typed with the values they accept.
export {
type Annotations,
arr,
bool,
bytes,
int,
type LengthBounds,
lit,
map,
type NumberBounds,
nul,
nullable,
num,
Optional,
obj,
oneOf,
opt,
type Properties,
type Schema,
type Static,
type StaticObject,
str,
tuple,
union,
unknown,
} from "./builders.ts";
export type { Check, CompileOptions, FormatCheck, JsonSchema, ValidationError } from "./types.ts";
export { compile, Validator } from "./validator.ts";

View file

@ -0,0 +1,152 @@
// Reference resolution of one compile: schema resources, anchors and JSON Pointers over the root schema and the
// registered documents. A Resolver lives only while a schema compiles (or while its errors are explained).
import type { CompileOptions, JsonSchema, SchemaObject } from "./types.ts";
/** A schema and the base URI its references resolve against. */
export interface Resolved {
readonly node: unknown;
readonly base: string;
}
/** The base URI of a root schema without `$id`; relative references resolve against it. */
export const DEFAULT_BASE = "https://json-schema.invalid/root.json";
/** Indexes the root schema and resolves references in it and in the registered documents. */
export class Resolver {
readonly draft: "2020-12" | "7";
readonly rootBase: string;
/** Schema object to the base URI it was indexed under. */
readonly baseOf = new Map<object, string>();
/** URI (with a `#name` fragment) of every `$dynamicAnchor`, to its schema. */
readonly dynamicAnchors = new Map<string, SchemaObject>();
private readonly registry = new Map<string, JsonSchema>();
private readonly resources = new Map<string, unknown>();
private readonly anchors = new Map<string, SchemaObject>();
constructor(root: JsonSchema, options: CompileOptions) {
this.draft = options.draft === "7" ? "7" : "2020-12";
for (const [uri, schema] of Object.entries(options.schemas ?? {})) {
this.registry.set(documentOf(resolveUri(uri, DEFAULT_BASE)), schema);
}
this.resources.set(DEFAULT_BASE, root);
this.index(root, DEFAULT_BASE);
this.rootBase = (isSchemaObject(root) && this.baseOf.get(root)) || DEFAULT_BASE;
}
/** The schema a reference names, with the base URI it compiles under. Throws if it names nothing. */
resolve(ref: string, base: string): Resolved {
const uri = resolveUri(ref, base);
const doc = documentOf(uri);
const fragment = uri.length > doc.length ? decodeURIComponent(uri.slice(doc.length + 1)) : "";
const root = this.resources.has(doc) ? this.resources.get(doc) : this.load(doc);
if (root === undefined) throw new Error(`Unresolved $ref ${ref} (no schema registered for ${doc})`);
if (fragment === "") return { node: root, base: doc };
if (fragment.startsWith("/")) return this.pointer(root, doc, fragment);
const target = this.anchors.get(`${doc}#${fragment}`);
if (target === undefined) throw new Error(`Unresolved $ref ${ref} (no anchor ${fragment} in ${doc})`);
return { node: target, base: this.baseOf.get(target) ?? doc };
}
/** Whether `node` is the root of the schema resource at `base`. */
isResource(node: object, base: string): boolean {
return this.resources.get(base) === node;
}
// Walks only keywords that hold subschemas: an `$id` inside `enum`, `const` or an unknown keyword names nothing.
private index(node: unknown, base: string): void {
if (!isSchemaObject(node) || this.baseOf.has(node)) return;
let b = base;
// In draft 7 a `$ref` makes every sibling keyword, `$id` included, inert.
const refOnly = this.draft === "7" && typeof node.$ref === "string";
if (!refOnly && typeof node.$id === "string") {
if (this.draft === "7" && node.$id.startsWith("#")) this.anchors.set(documentOf(b) + node.$id, node);
else {
const uri = resolveUri(node.$id, b);
b = documentOf(uri);
this.resources.set(b, node);
if (uri.length > b.length + 1) this.anchors.set(uri, node);
}
}
this.baseOf.set(node, b);
if (refOnly) return;
if (this.draft !== "7") {
if (typeof node.$anchor === "string") this.anchors.set(`${b}#${node.$anchor}`, node);
if (typeof node.$dynamicAnchor === "string") {
this.anchors.set(`${b}#${node.$dynamicAnchor}`, node);
this.dynamicAnchors.set(`${b}#${node.$dynamicAnchor}`, node);
}
}
for (const key of SCHEMA_KEYWORDS) this.index(node[key], b);
for (const key of MAP_KEYWORDS) {
const map = node[key];
if (isSchemaObject(map)) for (const k of Object.keys(map)) this.index(map[k], b);
}
for (const key of ARRAY_KEYWORDS) {
const list = node[key];
if (Array.isArray(list)) for (const s of list) this.index(s, b);
}
}
private load(doc: string): JsonSchema | undefined {
const schema = this.registry.get(doc);
if (schema === undefined) return undefined;
this.resources.set(doc, schema);
this.index(schema, doc);
return schema;
}
private pointer(root: unknown, base: string, fragment: string): Resolved {
let node = root;
let b = base;
for (const raw of fragment.split("/").slice(1)) {
const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
node =
typeof node === "object" && node !== null && Object.hasOwn(node, key)
? (node as SchemaObject)[key]
: undefined;
if (node === undefined) throw new Error(`Unresolved $ref #${fragment} in ${base}`);
if (typeof node === "object" && node !== null) b = this.baseOf.get(node) ?? b;
}
return { node, base: b };
}
}
const SCHEMA_KEYWORDS = [
"additionalProperties",
"propertyNames",
"contains",
"not",
"if",
"then",
"else",
"unevaluatedProperties",
"unevaluatedItems",
"additionalItems",
"contentSchema",
"items",
];
const MAP_KEYWORDS = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"];
const ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems", "items"];
/** Whether `value` is a schema object (not `true`/`false`, an array or a primitive). */
export function isSchemaObject(value: unknown): value is SchemaObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** `ref` resolved against `base`. URNs and other bases a URL parser rejects take fragment-only references. */
export function resolveUri(ref: string, base: string): string {
try {
return new URL(ref, base).href;
} catch {
return ref.startsWith("#") ? documentOf(base) + ref : ref;
}
}
/** `uri` without its fragment. */
export function documentOf(uri: string): string {
const hash = uri.indexOf("#");
return hash < 0 ? uri : uri.slice(0, hash);
}

View file

@ -0,0 +1,50 @@
// Values shared across all compiled schemas while one still uses them: leaf checks and regular expressions.
// Both caches hold their values weakly and drop an entry once its value is collected, so they keep nothing alive.
import type { Check } from "./types.ts";
/** The check shared under `key`, made by `make` when no live schema holds one. */
export function sharedCheck(key: string, make: () => Check): Check {
const hit = checks.get(key)?.deref();
if (hit !== undefined) return hit;
const check = make();
checks.set(key, new WeakRef(check));
checkCleanup.register(check, key);
return check;
}
const checks = new Map<string, WeakRef<Check>>();
const checkCleanup = new FinalizationRegistry<string>((key) => {
if (checks.get(key)?.deref() === undefined) checks.delete(key);
});
/**
* The regular expression of a `pattern` source, shared by source. Patterns are ECMAScript regular expressions with
* the `u` flag; a source that is invalid with it (some older schemas escape characters `u` rejects) compiles
* without it.
*/
export function sharedRegex(source: string): RegExp {
const hit = regexes.get(source)?.deref();
if (hit !== undefined) return hit;
let re: RegExp;
try {
re = new RegExp(source, "u");
} catch {
re = new RegExp(source);
}
regexes.set(source, new WeakRef(re));
regexCleanup.register(re, source);
return re;
}
const regexes = new Map<string, WeakRef<RegExp>>();
const regexCleanup = new FinalizationRegistry<string>((source) => {
if (regexes.get(source)?.deref() === undefined) regexes.delete(source);
});
/** The number of entries in both caches (for tests). */
export function sharedEntries(): { checks: number; regexes: number } {
return { checks: checks.size, regexes: regexes.size };
}

View file

@ -0,0 +1,36 @@
// Public types: the schema accepted by compile(), its options, and the errors a failed value produces.
/**
* A JSON Schema: `true`, `false` or a schema object. Schemas come from tool definitions, MCP servers and other
* untrusted sources, so compile() reads every keyword defensively instead of trusting a static shape.
*/
export type JsonSchema = boolean | object;
/** A format check: whether `value` (a string) has the format. */
export type FormatCheck = (value: string) => boolean;
/** Options of compile(). */
export interface CompileOptions {
/** The draft whose semantics apply: "2020-12" (default) or "7". */
readonly draft?: "2020-12" | "7";
/**
* Format name to check. Without it `format` is an annotation, as draft 2020-12 specifies. The standard formats are
* in `@earendil-works/pi-schema/formats`.
*/
readonly formats?: Readonly<Record<string, FormatCheck>>;
/** Schema documents by URI, for `$ref`s to other documents. */
readonly schemas?: Readonly<Record<string, JsonSchema>>;
}
/** One failure of a value: where it is (a JSON Pointer, "" for the value itself), the failing keyword and a message. */
export interface ValidationError {
readonly path: string;
readonly keyword: string;
readonly message: string;
}
/** A schema object as compile() reads it: any keyword, of any value, checked where it is used. */
export type SchemaObject = { readonly [keyword: string]: unknown };
/** A compiled boolean check. */
export type Check = (value: unknown) => boolean;

View file

@ -0,0 +1,58 @@
// compile(): a schema to a Validator, whose `check` is the fast boolean path and whose `errors` explains failures.
import type { Static } from "./builders.ts";
import { Compiler, RESTART } from "./compiler.ts";
import { Explainer } from "./explain.ts";
import type { Check, CompileOptions, JsonSchema, ValidationError } from "./types.ts";
/**
* Compiles `schema` into a Validator. Throws if the schema is malformed or a `$ref` names nothing (register other
* documents with `options.schemas`). The schema is read, never modified.
*/
export function compile<S extends JsonSchema>(schema: S, options: CompileOptions = NO_OPTIONS): Validator<Static<S>> {
return new Validator(schema, options, compileCheck(schema, options));
}
const NO_OPTIONS: CompileOptions = Object.freeze({});
/** A compiled schema whose valid values have type `T` (`unknown` unless the schema was built with the builders). */
export class Validator<T = unknown> {
/** Whether `value` is valid. A plain function: it may be passed around without its validator. */
readonly check: (value: unknown) => value is T;
private readonly schema: JsonSchema;
private readonly options: CompileOptions;
private explainer: Explainer | undefined = undefined;
constructor(schema: JsonSchema, options: CompileOptions, check: Check) {
// A compiled check is exactly the test that a value has the schema's static type.
this.check = check as (value: unknown) => value is T;
this.schema = schema;
this.options = options;
}
/**
* Why `value` is invalid: one error per failing keyword, `[]` when it is valid. Built on the first failure and
* kept, so a validator whose values always pass never pays for it.
*/
errors(value: unknown): ValidationError[] {
if (this.check(value)) return [];
this.explainer ??= new Explainer(compiler(this.schema, this.options), this.schema);
return this.explainer.errors(value);
}
}
function compileCheck(schema: JsonSchema, options: CompileOptions): Check {
return compiler(schema, options).root();
}
// Most schemas compile without tracking the dynamic scope; one with a bookended $dynamicRef compiles again with it.
function compiler(schema: JsonSchema, options: CompileOptions): Compiler {
const plain = new Compiler(schema, options, false);
try {
plain.root();
return plain;
} catch (error) {
if (error !== RESTART) throw error;
return new Compiler(schema, options, true);
}
}

View file

@ -0,0 +1,36 @@
// Runs every benchmark, each in its own process, and writes the results as JSON next to the printed tables.
// Speed runs twice: normally and with code generation from strings disabled, as under a strict CSP.
// Run: npm run bench [-- --out <file>] (default: pi-schema-bench-results.json in the temp directory)
import { execFileSync } from "node:child_process";
import { readFileSync, rmSync, writeFileSync } from "node:fs";
import { cpus, tmpdir } from "node:os";
import { join } from "node:path";
const dir = import.meta.dirname;
const outIndex = process.argv.indexOf("--out");
const out = outIndex >= 0 ? process.argv[outIndex + 1] : join(tmpdir(), "pi-schema-bench-results.json");
const runs: [string, string, string[]][] = [
["conformance", "conformance.ts", []],
["speed", "speed.ts", []],
["speedNoEval", "speed.ts", ["--disallow-code-generation-from-strings"]],
["setup", "setup.ts", []],
["memory", "memory.ts", []],
["size", "size.ts", []],
];
const results: Record<string, unknown> = {
node: process.version,
cpu: cpus()[0]?.model ?? "unknown",
date: new Date().toISOString(),
};
for (const [key, file, flags] of runs) {
const json = join(tmpdir(), `pi-schema-bench-${key}.json`);
console.log(`\n## ${key}${flags.length > 0 ? ` (${flags.join(" ")})` : ""}\n`);
execFileSync(process.execPath, [...flags, join(dir, file), "--json", json], { stdio: "inherit" });
results[key] = JSON.parse(readFileSync(json, "utf8"));
rmSync(json);
}
writeFileSync(out, JSON.stringify(results, null, "\t"));
console.log(`\nResults: ${out}`);

View file

@ -0,0 +1,101 @@
// The validators the benchmarks compare, each behind the same interface: compile a schema, then check values.
// Every library is used through its fastest boolean path and keeps the object it returns, as an application would.
import { Validator as CfValidator } from "@cfworker/json-schema";
import { fromJsonSchema } from "@oh-my-pi/omptype";
import { Compile } from "typebox/compile";
import { ResetExternal, ResetFunctions } from "typebox/schema";
import { Settings } from "typebox/system";
import { compile, type FormatCheck, type JsonSchema } from "../../src/index.ts";
/** How a benchmark compiles a schema. */
export interface PrepareOptions {
readonly draft: "2020-12" | "7";
readonly schemas?: Readonly<Record<string, JsonSchema>>;
readonly formats?: Readonly<Record<string, FormatCheck>>;
}
/** A compiled schema: its boolean check and the object the library returned (kept alive by the caller). */
export interface Prepared {
readonly check: (value: unknown) => boolean;
readonly validator: unknown;
}
/** A library under comparison. */
export interface Candidate {
readonly name: string;
readonly prepare: (schema: JsonSchema, options: PrepareOptions) => Prepared;
}
/** Whether this process runs with code generation from strings disabled, as under a strict CSP. */
export const noEval = process.execArgv.includes("--disallow-code-generation-from-strings");
const piSchema: Candidate = {
name: "pi-schema",
prepare(schema, options) {
const validator = compile(schema, options);
return { check: validator.check, validator };
},
};
// TypeBox keeps its last build in module-level accumulators; reset them so no build state is retained between schemas.
function typeboxCompile(schema: JsonSchema, options: PrepareOptions, accelerate: boolean) {
Settings.Set({ useAcceleration: accelerate });
try {
return Compile((options.schemas ?? {}) as never, schema as never);
} finally {
ResetFunctions();
ResetExternal();
Settings.Set({ useAcceleration: true });
}
}
const typebox: Candidate = {
name: noEval ? "typebox (no eval: interpreter)" : "typebox",
prepare(schema, options) {
const validator = typeboxCompile(schema, options, true);
return { check: (v) => validator.Check(v), validator };
},
};
const typeboxInterpreter: Candidate = {
name: "typebox interpreter",
prepare(schema, options) {
const validator = typeboxCompile(schema, options, false);
return { check: (v) => validator.Check(v), validator };
},
};
const cfworker: Candidate = {
name: "cfworker",
prepare(schema, options) {
const validator = new CfValidator(schema as never, options.draft, true);
for (const [uri, remote] of Object.entries(options.schemas ?? {})) validator.addSchema(remote as never, uri);
return { check: (v) => validator.validate(v).valid, validator };
},
};
// omptype compiles with new Function on the first allows() call. Without eval that call throws EvalError once per
// schema; omptype then keeps validating through its interpreter, so the benchmark absorbs that one throw.
const omptype: Candidate = {
name: noEval ? "omptype (no eval: interpreter)" : "omptype",
prepare(schema) {
const validator = fromJsonSchema(schema);
if (noEval) {
try {
validator.allows(null);
} catch (error) {
if (!(error instanceof EvalError)) throw error;
}
}
return { check: (v) => validator.allows(v), validator };
},
};
/** Every candidate, ours first. */
export const candidates: readonly Candidate[] = [piSchema, typebox, cfworker, omptype];
/** Candidates by name, including TypeBox's interpreter (what TypeBox runs when eval is unavailable). */
export const byName: ReadonlyMap<string, Candidate> = new Map(
[...candidates, typeboxInterpreter].map((c) => [c.name.split(" (")[0], c]),
);

View file

@ -0,0 +1,261 @@
// Speed cases: realistic schemas with a valid and an invalid value each. Argument checks shaped like Chord's
// contracts (closed objects, tagged unions), pi's built-in tool schemas, and MCP tool schemas with $ref and anyOf.
import type { JsonSchema } from "../../src/index.ts";
/** A schema, the draft it is written in, and one valid and one invalid value. */
export interface Case {
readonly name: string;
readonly schema: JsonSchema;
readonly valid: unknown;
readonly invalid: unknown;
}
type Props = Record<string, object>;
const closed = (properties: Props, optional: string[] = []) => ({
type: "object",
properties,
required: Object.keys(properties).filter((k) => !optional.includes(k)),
additionalProperties: false,
});
const str = (o: object = {}) => ({ type: "string", ...o });
const int = (o: object = {}) => ({ type: "integer", ...o });
const num = (o: object = {}) => ({ type: "number", ...o });
const lit = (v: string) => ({ const: v });
const arr = (items: object, o: object = {}) => ({ type: "array", items, ...o });
const uuid = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
const block = {
anyOf: [
closed({ kind: lit("text"), text: str() }),
closed({ kind: lit("image"), src: str(), width: int({ minimum: 0 }), height: int({ minimum: 0 }), alt: str() }, [
"alt",
]),
closed({ kind: lit("code"), lang: { anyOf: [lit("ts"), lit("js"), lit("py")] }, source: str() }),
],
};
const document = closed(
{
id: str({ pattern: uuid }),
title: str({ minLength: 1, maxLength: 200 }),
tags: arr(str(), { maxItems: 16 }),
author: closed({ name: str(), email: str() }, ["email"]),
blocks: arr(block),
score: num({ minimum: 0, maximum: 1 }),
draft: { type: "boolean" },
},
["score", "draft"],
);
const documentValue = {
id: "01890a5d-ac96-774b-bcce-b302099a8057",
title: "Quarterly notes",
tags: ["a", "b", "c"],
author: { name: "Mario", email: "m@example.com" },
blocks: [
{ kind: "text", text: "Hello" },
{ kind: "image", src: "a.png", width: 640, height: 480 },
{ kind: "code", lang: "ts", source: "const x = 1;" },
{ kind: "text", text: "World" },
{ kind: "image", src: "b.png", width: 10, height: 20, alt: "b" },
],
score: 0.5,
};
const id = int({ minimum: 0 });
const message = {
anyOf: [
closed({
kind: lit("hello"),
protocol: int({ minimum: 0 }),
incarnation: str({ pattern: uuid }),
calls: int({ minimum: 1 }),
}),
closed(
{
kind: lit("hold"),
hold: id,
type: str(),
version: int({ minimum: 1 }),
fingerprint: str({ pattern: "^[0-9a-f]{16}$" }),
params: {},
address: closed({ kind: str(), params: {} }),
chain: arr(str({ pattern: uuid }), { maxItems: 64 }),
},
["chain"],
),
closed({ kind: lit("release"), hold: id }),
closed({ kind: lit("call"), call: id, hold: id, method: id, args: {} }, ["args"]),
closed({ kind: lit("abort"), call: id }),
],
};
const tool = (properties: Props, required: string[]) => ({ type: "object", properties, required });
const mcpIssue = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
owner: str({ minLength: 1, pattern: "^[A-Za-z0-9-]+$" }),
repo: str({ minLength: 1, pattern: "^[A-Za-z0-9._-]+$" }),
title: str({ minLength: 1 }),
body: str(),
labels: arr(str()),
assignees: arr(str()),
milestone: { anyOf: [{ type: "number" }, { type: "null" }] },
state: { type: "string", enum: ["open", "closed"] },
},
required: ["owner", "repo", "title"],
additionalProperties: false,
};
const mcpEdit = {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
$defs: {
Range: closed({ start: int({ minimum: 1 }), end: int({ minimum: 1 }) }),
Replace: closed({ oldText: str(), newText: str() }),
LineEdit: closed({ range: { $ref: "#/$defs/Range" }, text: str() }),
},
properties: {
path: str({ pattern: "^(/|\\./|[A-Za-z]:\\\\)?[^\\u0000]+$" }),
edits: arr({ anyOf: [{ $ref: "#/$defs/Replace" }, { $ref: "#/$defs/LineEdit" }] }, { minItems: 1 }),
dryRun: { type: "boolean" },
},
required: ["path", "edits"],
additionalProperties: false,
};
const mcpPage = {
type: "object",
$defs: {
RichText: closed(
{
type: { enum: ["text", "mention"] },
content: str({ maxLength: 2000 }),
annotations: closed({ bold: { type: "boolean" }, italic: { type: "boolean" }, color: str() }, [
"bold",
"italic",
"color",
]),
},
["annotations"],
),
Block: {
type: "object",
properties: {
type: { enum: ["paragraph", "heading_1", "heading_2", "bulleted_list_item", "to_do"] },
rich_text: arr({ $ref: "#/$defs/RichText" }),
checked: { type: "boolean" },
children: arr({ $ref: "#/$defs/Block" }),
},
required: ["type", "rich_text"],
additionalProperties: false,
},
},
properties: {
parent: { oneOf: [closed({ page_id: str({ pattern: uuid }) }), closed({ database_id: str({ pattern: uuid }) })] },
title: str({ minLength: 1 }),
due: str({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" }),
children: arr({ $ref: "#/$defs/Block" }, { maxItems: 100 }),
},
required: ["parent", "title"],
additionalProperties: false,
};
const rt = (content: string) => ({
type: "text",
content,
annotations: { bold: false, italic: false, color: "default" },
});
const pageValue = {
parent: { page_id: "01890a5d-ac96-774b-bcce-b302099a8057" },
title: "Plan",
due: "2026-11-01",
children: [
{ type: "heading_1", rich_text: [rt("Plan")] },
{
type: "bulleted_list_item",
rich_text: [rt("one")],
children: [{ type: "to_do", rich_text: [rt("sub")], checked: true }],
},
{ type: "paragraph", rich_text: [rt("a"), rt("b"), rt("c")] },
],
};
const pageInvalid = structuredClone(pageValue);
pageInvalid.children[1].children = [{ type: "to_do", rich_text: [rt("sub")], checked: "yes" as unknown as boolean }];
/** The speed cases. */
export const cases: readonly Case[] = [
{ name: "small closed object", schema: closed({ text: str() }), valid: { text: "hello" }, invalid: { text: 1 } },
{
name: "nested object with tagged unions",
schema: document,
valid: documentValue,
invalid: {
...documentValue,
blocks: [...documentValue.blocks.slice(0, 4), { kind: "image", src: "b.png", width: -1, height: 20 }],
},
},
{
name: "message union of 5",
schema: message,
valid: { kind: "call", call: 7, hold: 3, method: 1, args: { a: 1 } },
invalid: { kind: "call", call: 7, hold: 3, method: 1, extra: true },
},
{
name: "tool: read",
schema: tool({ path: str({ description: "Path to the file" }), offset: num(), limit: num() }, ["path"]),
valid: { path: "src/a.ts", offset: 10, limit: 50 },
invalid: { offset: 1 },
},
{
name: "tool: edit",
schema: tool({ path: str(), edits: arr(tool({ oldText: str(), newText: str() }, ["oldText", "newText"])) }, [
"path",
"edits",
]),
valid: {
path: "a.ts",
edits: [
{ oldText: "a", newText: "b" },
{ oldText: "c", newText: "d" },
],
},
invalid: { path: "a.ts", edits: [{ oldText: "a" }] },
},
{
name: "tool: bash",
schema: tool({ command: str(), timeout: num() }, ["command"]),
valid: { command: "ls -la", timeout: 30 },
invalid: { command: 5 },
},
{
name: "MCP create_issue (pattern, anyOf, enum)",
schema: mcpIssue,
valid: {
owner: "earendil-works",
repo: "pi",
title: "Bug",
body: "...",
labels: ["bug"],
milestone: null,
state: "open",
},
invalid: { owner: "earendil works", repo: "pi", title: "Bug" },
},
{
name: "MCP edit_file ($ref, anyOf)",
schema: mcpEdit,
valid: {
path: "./src/a.ts",
edits: [
{ oldText: "a", newText: "b" },
{ range: { start: 1, end: 3 }, text: "x" },
],
},
invalid: { path: "./src/a.ts", edits: [{ range: { start: 0, end: 3 }, text: "x" }] },
},
{ name: "MCP create_page (recursive $ref, oneOf)", schema: mcpPage, valid: pageValue, invalid: pageInvalid },
];

View file

@ -0,0 +1,54 @@
// Conformance of every candidate on the vendored JSON-Schema-Test-Suite (drafts 2020-12 and 7), by section:
// core, optional and optional/format. Every candidate gets the remote documents and meta-schemas; format tests run
// with pi-schema's format checks (the other libraries check formats on their own or not at all).
// Run: node test/bench/conformance.ts [--json <file>]
import { writeFileSync } from "node:fs";
import { formats } from "../../src/formats/index.ts";
import { suiteFiles, suiteSchemas } from "../suite.ts";
import { candidates } from "./candidates.ts";
/** Passed and total tests by section ("draft2020-12 core", ...) and candidate name. */
export type ConformanceResults = Record<string, { total: number; passed: Record<string, number> }>;
const results: ConformanceResults = {};
for (const file of suiteFiles()) {
const kind = file.name.includes("/optional/format/")
? "format"
: file.name.includes("/optional/")
? "optional"
: "core";
const section = `${file.name.split("/")[0]} ${kind}`;
results[section] ??= { total: 0, passed: Object.fromEntries(candidates.map((c) => [c.name, 0])) };
for (const group of file.groups) {
results[section].total += group.tests.length;
for (const candidate of candidates) {
let check: ((v: unknown) => boolean) | undefined;
try {
check = candidate.prepare(structuredClone(group.schema), {
draft: file.draft,
schemas: suiteSchemas(),
formats: kind === "format" ? formats : undefined,
}).check;
} catch {
continue; // A schema the library cannot compile fails all its tests.
}
for (const test of group.tests) {
try {
if (check(test.data) === test.valid) results[section].passed[candidate.name]++;
} catch {
// A throw while checking fails the test.
}
}
}
}
}
const names = candidates.map((c) => c.name);
console.log(`| section | tests | ${names.join(" | ")} |`);
console.log(`|---|---:|${names.map(() => "---:").join("|")}|`);
for (const [section, r] of Object.entries(results)) {
console.log(`| ${section} | ${r.total} | ${names.map((n) => r.passed[n]).join(" | ")} |`);
}
const out = process.argv.indexOf("--json");
if (out >= 0) writeFileSync(process.argv[out + 1], JSON.stringify(results, null, "\t"));

View file

@ -0,0 +1,122 @@
// A deterministic corpus of distinct tool-like JSON Schemas with one valid value each: random property names,
// descriptions, leaf constraints, enums, nested objects, arrays and some $defs/$ref. No two schemas are copies.
/** A schema and a value it accepts. */
export interface CorpusItem {
readonly schema: Record<string, unknown>;
readonly value: unknown;
}
const SYLLABLES = [
"ka",
"lo",
"mi",
"re",
"su",
"ta",
"ne",
"vo",
"pi",
"da",
"ru",
"xe",
"bo",
"fi",
"gu",
"ha",
"jo",
"ze",
];
const PATTERNS: readonly (readonly [string, string])[] = [
["^[a-z0-9-]+$", "a-1"],
["^[A-Za-z0-9._-]+$", "A.b_c"],
["^\\d{4}-\\d{2}-\\d{2}$", "2026-10-31"],
["^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "01890a5d-ac96-774b-bcce-b302099a8057"],
["^#[0-9a-f]{6}$", "#00ff00"],
];
/** `count` distinct schemas, the same for the same seed. */
export function corpus(count: number, seed = 42): CorpusItem[] {
const random = mulberry32(seed);
const int = (lo: number, hi: number) => lo + Math.floor(random() * (hi - lo + 1));
const pick = <T>(list: readonly T[]): T => list[Math.floor(random() * list.length)];
const word = () => Array.from({ length: int(2, 4) }, () => pick(SYLLABLES)).join("");
const sentence = () => `${Array.from({ length: int(4, 12) }, word).join(" ")}.`;
function leaf(depth: number): [Record<string, unknown>, unknown] {
const r = random();
if (r < 0.3) return [{ type: "string", description: sentence() }, word()];
if (r < 0.4) {
const min = int(0, 3);
return [
{ type: "string", minLength: min, maxLength: min + int(5, 200), description: sentence() },
"x".repeat(min + 1),
];
}
if (r < 0.47) {
const [pattern, value] = pick(PATTERNS);
return [{ type: "string", pattern, description: sentence() }, value];
}
if (r < 0.55) {
const values = Array.from({ length: int(2, 5) }, word);
return [{ type: "string", enum: values, description: sentence() }, values[0]];
}
if (r < 0.65) {
const min = int(0, 10);
return [{ type: "integer", minimum: min, maximum: min + int(10, 10000), description: sentence() }, min + 1];
}
if (r < 0.72) return [{ type: "number", description: sentence() }, random() * 100];
if (r < 0.8) return [{ type: "boolean", description: sentence() }, random() < 0.5];
if (r < 0.88) {
return [{ type: "array", items: { type: "string" }, maxItems: int(1, 50), description: sentence() }, [word()]];
}
if (depth < 2) return object(depth + 1, int(2, 5));
return [{ type: "string", description: sentence() }, word()];
}
function object(depth: number, n: number): [Record<string, unknown>, Record<string, unknown>] {
const properties: Record<string, Record<string, unknown>> = {};
const value: Record<string, unknown> = {};
const required: string[] = [];
for (let i = 0; i < n; i++) {
const name = word() + (i > 0 ? i : "");
const [schema, v] = leaf(depth);
properties[name] = schema;
if (random() < 0.6) {
required.push(name);
value[name] = v;
} else if (random() < 0.5) value[name] = v;
}
const schema: Record<string, unknown> = { type: "object", properties, required };
if (random() < 0.5) schema.additionalProperties = false;
return [schema, value];
}
const items: CorpusItem[] = [];
for (let i = 0; i < count; i++) {
const [schema, value] = object(0, int(2, 10));
const properties = schema.properties as Record<string, Record<string, unknown>>;
if (random() < 0.2) {
// Move one nested object into $defs and reference it, as MCP servers built with zod often do.
const key = Object.keys(properties).find((k) => properties[k].type === "object");
if (key !== undefined) {
const name = `${word()}Def`;
schema.$defs = { [name]: properties[key] };
properties[key] = { $ref: `#/$defs/${name}` };
}
}
schema.description = sentence();
items.push({ schema, value });
}
return items;
}
function mulberry32(seed: number): () => number {
let state = seed;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

View file

@ -0,0 +1,73 @@
// Retained heap of 3,200 distinct compiled schemas per candidate. Each candidate runs in its own process with
// --expose-gc: the schemas and values are built before the baseline, every library's returned validator is kept,
// and the heap is read after forced collections, once after compiling and once after checking each value 3 times
// (when libraries that compile lazily, like omptype, have compiled).
// Run: node test/bench/memory.ts [--json <file>]
import { execFileSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { byName, candidates } from "./candidates.ts";
import { corpus } from "./corpus.ts";
const COUNT = Number(process.env.COUNT ?? 3200);
/** MB retained by the schemas themselves, and by each candidate after compiling and after checking. */
export interface MemoryResults {
readonly count: number;
readonly schemasMB: number;
readonly candidates: Record<string, { readonly compiledMB: number; readonly checkedMB: number }>;
}
const child = process.argv.indexOf("--child");
if (child >= 0) measure(process.argv[child + 1]);
else run();
function run(): void {
const results: MemoryResults = { count: COUNT, schemasMB: 0, candidates: {} };
const names = [...candidates.map((c) => c.name), "typebox interpreter"];
for (const name of names) {
const output = execFileSync(process.execPath, ["--expose-gc", import.meta.filename, "--child", name], {
encoding: "utf8",
env: { ...process.env, COUNT: String(COUNT) },
});
const result = JSON.parse(output);
Object.assign(results, { schemasMB: result.schemasMB });
results.candidates[name] = { compiledMB: result.compiledMB, checkedMB: result.checkedMB };
}
console.log(`${COUNT} distinct schemas take ${results.schemasMB.toFixed(1)} MB themselves (not counted below).\n`);
console.log("| candidate | after compiling | after checking |");
console.log("|---|---:|---:|");
for (const [name, r] of Object.entries(results.candidates)) {
console.log(`| ${name} | ${r.compiledMB.toFixed(1)} MB | ${r.checkedMB.toFixed(1)} MB |`);
}
const out = process.argv.indexOf("--json");
if (out >= 0) writeFileSync(process.argv[out + 1], JSON.stringify(results, null, "\t"));
}
function measure(name: string): void {
const candidate = byName.get(name);
if (candidate === undefined) throw new Error(`Unknown candidate ${name}`);
const gc = (globalThis as { gc?: () => void }).gc;
if (gc === undefined) throw new Error("Run with --expose-gc");
const heap = () => {
for (let i = 0; i < 4; i++) gc();
return process.memoryUsage().heapUsed;
};
// Warm the library's own code on another corpus, so its lazily compiled functions are not counted.
for (const { schema, value } of corpus(100, 7)) {
const { check } = candidate.prepare(schema, { draft: "2020-12" });
for (let i = 0; i < 4; i++) check(value);
}
const empty = heap();
const items = corpus(COUNT, 42);
const base = heap();
const prepared = items.map(({ schema }) => candidate.prepare(schema, { draft: "2020-12" }));
const compiled = heap();
for (let i = 0; i < items.length; i++) for (let k = 0; k < 3; k++) prepared[i].check(items[i].value);
const checked = heap();
(globalThis as { keep?: unknown }).keep = [items, prepared];
const mb = (bytes: number) => bytes / 1048576;
console.log(
JSON.stringify({ schemasMB: mb(base - empty), compiledMB: mb(compiled - base), checkedMB: mb(checked - base) }),
);
}

View file

@ -0,0 +1,51 @@
// Setup cost: compiling a schema the library has not seen, and the time of its first checks. Uses distinct
// schemas from the corpus after warming each library's own code, so it measures a new schema, not a new process.
// Run: node test/bench/setup.ts [--json <file>]
import { writeFileSync } from "node:fs";
import { candidates } from "./candidates.ts";
import { corpus } from "./corpus.ts";
const COUNT = Number(process.env.COUNT ?? 1000);
/** Mean µs per schema: compile, then each of the first three checks and the mean of checks 4-10. */
export type SetupResults = Record<string, { compileUs: number; checkUs: [number, number, number, number] }>;
const results: SetupResults = {};
for (const candidate of candidates) {
for (const { schema, value } of corpus(200, 7)) {
const { check } = candidate.prepare(schema, { draft: "2020-12" });
for (let i = 0; i < 10; i++) check(value);
}
const items = corpus(COUNT, 99);
let compile = 0;
const checks = [0, 0, 0, 0];
for (const { schema, value } of items) {
let start = process.hrtime.bigint();
const { check } = candidate.prepare(schema, { draft: "2020-12" });
compile += Number(process.hrtime.bigint() - start);
for (let k = 0; k < 3; k++) {
start = process.hrtime.bigint();
check(value);
checks[k] += Number(process.hrtime.bigint() - start);
}
start = process.hrtime.bigint();
for (let k = 3; k < 10; k++) check(value);
checks[3] += Number(process.hrtime.bigint() - start) / 7;
}
const us = (ns: number) => ns / COUNT / 1000;
results[candidate.name] = {
compileUs: us(compile),
checkUs: [us(checks[0]), us(checks[1]), us(checks[2]), us(checks[3])],
};
}
console.log("| candidate | compile | check 1 | check 2 | check 3 | checks 4-10 |");
console.log("|---|---:|---:|---:|---:|---:|");
for (const [name, r] of Object.entries(results)) {
console.log(
`| ${name} | ${r.compileUs.toFixed(1)} µs | ${r.checkUs.map((x) => `${x.toFixed(2)} µs`).join(" | ")} |`,
);
}
const out = process.argv.indexOf("--json");
if (out >= 0) writeFileSync(process.argv[out + 1], JSON.stringify(results, null, "\t"));

View file

@ -0,0 +1,43 @@
// Bundle size of what a validator needs from each library: esbuild bundles and minifies the import, then gzip -9.
// Run: node test/bench/size.ts [--json <file>]
import { writeFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { build } from "esbuild";
const src = new URL("../../src/", import.meta.url).pathname;
const entries: Record<string, string> = {
"pi-schema": `export { compile } from ${JSON.stringify(`${src}index.ts`)};`,
"pi-schema + formats": `export { compile } from ${JSON.stringify(`${src}index.ts`)}; export { formats } from ${JSON.stringify(`${src}formats/index.ts`)};`,
typebox: `export { Compile } from "typebox/compile";`,
cfworker: `export { Validator } from "@cfworker/json-schema";`,
omptype: `export { fromJsonSchema } from "@oh-my-pi/omptype";`,
};
/** Minified and gzipped bytes by entry. */
export type SizeResults = Record<string, { minified: number; gzip: number }>;
const results: SizeResults = {};
for (const [name, contents] of Object.entries(entries)) {
const output = await build({
stdin: { contents, resolveDir: src, loader: "ts" },
bundle: true,
minify: true,
format: "esm",
platform: "neutral",
mainFields: ["module", "main"],
write: false,
logLevel: "silent",
});
const code = output.outputFiles[0].contents;
results[name] = { minified: code.length, gzip: gzipSync(code, { level: 9 }).length };
}
console.log("| library | minified | gzip |");
console.log("|---|---:|---:|");
for (const [name, r] of Object.entries(results)) {
console.log(`| ${name} | ${(r.minified / 1024).toFixed(1)} KB | ${(r.gzip / 1024).toFixed(1)} KB |`);
}
const out = process.argv.indexOf("--json");
if (out >= 0) writeFileSync(process.argv[out + 1], JSON.stringify(results, null, "\t"));

View file

@ -0,0 +1,62 @@
// Hot validation speed: ns per check for each case and candidate, after warm-up. Each round times every
// candidate once, in a rotated order; the result is the median of the rounds.
// Run: node test/bench/speed.ts [--json <file>] (add --disallow-code-generation-from-strings for no eval)
import { writeFileSync } from "node:fs";
import { candidates } from "./candidates.ts";
import { cases } from "./cases.ts";
const ROUNDS = Number(process.env.ROUNDS ?? 15);
const ROUND_MS = Number(process.env.ROUND_MS ?? 30);
let sink = 0;
function time(check: (v: unknown) => boolean, value: unknown, n: number): number {
const start = process.hrtime.bigint();
for (let i = 0; i < n; i++) if (check(value)) sink++;
return Number(process.hrtime.bigint() - start) / n;
}
function median(values: number[]): number {
return [...values].sort((a, b) => a - b)[values.length >> 1];
}
/** Median ns per check, by case name, input ("valid"/"invalid") and candidate name. */
export type SpeedResults = Record<string, Record<"valid" | "invalid", Record<string, number>>>;
const results: SpeedResults = {};
for (const c of cases) {
results[c.name] = { valid: {}, invalid: {} };
for (const input of ["valid", "invalid"] as const) {
const value = c[input];
const runs = candidates.map((candidate) => {
const { check } = candidate.prepare(c.schema, { draft: "2020-12" });
if (check(value) !== (input === "valid")) throw new Error(`${candidate.name} gets ${c.name} (${input}) wrong`);
let n = 1000;
while (time(check, value, n) * n < ROUND_MS * 1e6 && n < 1e8) n *= 2;
return { candidate, check, n, samples: [] as number[] };
});
for (let round = 0; round < ROUNDS; round++) {
for (let i = 0; i < runs.length; i++) {
const run = runs[(i + round) % runs.length];
run.samples.push(time(run.check, value, run.n));
}
}
for (const run of runs) results[c.name][input][run.candidate.name] = median(run.samples);
}
}
const names = candidates.map((c) => c.name);
console.log(`| case | input | ${names.join(" | ")} |`);
console.log(`|---|---|${names.map(() => "---:").join("|")}|`);
for (const [name, inputs] of Object.entries(results)) {
for (const [input, ns] of Object.entries(inputs)) {
console.log(`| ${name} | ${input} | ${names.map((n) => format(ns[n])).join(" | ")} |`);
}
}
const out = process.argv.indexOf("--json");
if (out >= 0) writeFileSync(process.argv[out + 1], JSON.stringify(results, null, "\t"));
if (sink < 0) console.log(sink);
function format(ns: number): string {
return ns >= 1000 ? `${(ns / 1000).toFixed(ns >= 10000 ? 0 : 1)} µs` : `${ns.toFixed(0)} ns`;
}

View file

@ -0,0 +1,104 @@
// The builders: the JSON Schema they produce, the static types they carry, and how they compile.
import { describe, expect, expectTypeOf, it } from "vitest";
import {
arr,
bool,
bytes,
compile,
int,
lit,
map,
nul,
nullable,
num,
obj,
oneOf,
opt,
type Static,
str,
tuple,
union,
unknown,
} from "../src/index.ts";
describe("builders", () => {
it("produce plain JSON Schema", () => {
const schema = obj({
path: str({ description: "File path", minLength: 1 }),
offset: opt(int({ minimum: 1 })),
mode: oneOf(["read", "write"]),
});
expect(JSON.parse(JSON.stringify(schema))).toEqual({
type: "object",
properties: {
path: { type: "string", description: "File path", minLength: 1 },
offset: { type: "integer", minimum: 1 },
mode: { enum: ["read", "write"] },
},
required: ["path", "mode"],
additionalProperties: false,
});
});
it("produce every leaf", () => {
expect([str(), num(), int(), bool(), nul(), unknown(), lit(3), bytes({ maxLength: 8 })]).toEqual([
{ type: "string" },
{ type: "number" },
{ type: "integer" },
{ type: "boolean" },
{ type: "null" },
{},
{ const: 3 },
{ type: "bytes", maxLength: 8 },
]);
expect(arr(str(), { maxItems: 2 })).toEqual({ type: "array", items: { type: "string" }, maxItems: 2 });
expect(map(num())).toEqual({ type: "object", additionalProperties: { type: "number" } });
expect(tuple([str(), num()])).toEqual({
type: "array",
prefixItems: [{ type: "string" }, { type: "number" }],
items: false,
minItems: 2,
});
expect(nullable(str())).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] });
expect(obj({ a: str() }, { open: true })).toEqual({
type: "object",
properties: { a: { type: "string" } },
required: ["a"],
});
});
it("carry the static type of valid values", () => {
const message = union([
obj({ kind: lit("text"), text: str() }),
obj({ kind: lit("image"), url: str(), alt: opt(str()) }),
]);
expectTypeOf<Static<typeof message>>().toEqualTypeOf<
{ kind: "text"; text: string } | { kind: "image"; url: string; alt?: string }
>();
const pair = tuple([str(), arr(int())]);
expectTypeOf<Static<typeof pair>>().toEqualTypeOf<[string, number[]]>();
expectTypeOf<Static<ReturnType<typeof nullable<string>>>>().toEqualTypeOf<string | null>();
expectTypeOf<Static<{ type: "string" }>>().toEqualTypeOf<unknown>();
});
it("narrow values through a compiled check", () => {
const point = obj({ x: num(), y: num(), label: opt(str()) });
const value: unknown = { x: 1, y: 2 };
const { check } = compile(point);
if (!check(value)) throw new Error("expected a point");
expectTypeOf(value).toEqualTypeOf<{ x: number; y: number; label?: string }>();
expect(value.x + value.y).toBe(3);
});
it("compile into checks with the declared semantics", () => {
const validator = compile(obj({ name: str({ minLength: 1 }), tags: opt(arr(str(), { uniqueItems: true })) }));
expect(validator.check({ name: "a" })).toBe(true);
expect(validator.check({ name: "a", tags: ["x", "y"] })).toBe(true);
expect(validator.errors({ name: "", tags: ["x", "x"], other: 1 })).toEqual([
{ path: "/name", keyword: "minLength", message: "expected at least 1 characters" },
{ path: "/tags", keyword: "uniqueItems", message: "expected unique items" },
{ path: "/other", keyword: "additionalProperties", message: "unknown field" },
]);
});
});

View file

@ -0,0 +1,51 @@
// The official JSON-Schema-Test-Suite, drafts 2020-12 and 7: every test, including optional and format tests.
// A test listed in KNOWN_FAILURES must fail; when one starts passing, remove it from the list.
import { describe, expect, it } from "vitest";
import { formats } from "../src/formats/index.ts";
import { compile } from "../src/index.ts";
import { suiteFiles, suiteSchemas } from "./suite.ts";
const KNOWN_FAILURES = new Set([
// Vocabularies switched off by a custom meta-schema are not supported.
"draft2020-12/vocabulary > schema that uses custom metaschema with with no validation vocabulary > no validation: invalid number, but it still validates",
"draft2020-12/optional/format-assertion > schema that uses custom metaschema with format-assertion: false > format-assertion: false: invalid string",
"draft2020-12/optional/format-assertion > schema that uses custom metaschema with format-assertion: true > format-assertion: true: invalid string",
// A $ref to a document of another draft is evaluated with the compile's draft.
"draft2020-12/optional/cross-draft > refs to historic drafts are processed as historic drafts > first item not a string is valid",
"draft7/optional/cross-draft > refs to future drafts are processed as future drafts > missing bar is invalid",
// contentMediaType and contentEncoding are annotations.
"draft7/optional/content > validation of string-encoded content based on media type > an invalid JSON document",
"draft7/optional/content > validation of binary string-encoding > an invalid base64 string (% is not a valid character)",
"draft7/optional/content > validation of binary-encoded media type documents > a validly-encoded invalid JSON document",
"draft7/optional/content > validation of binary-encoded media type documents > an invalid base64 string that is valid JSON",
]);
const schemas = suiteSchemas();
for (const file of suiteFiles()) {
// Format tests assert formats; everywhere else `format` is an annotation, as draft 2020-12 specifies.
const options = {
draft: file.draft,
schemas,
formats: file.name.includes("/optional/format/") ? formats : undefined,
};
describe(file.name, () => {
for (const group of file.groups) {
describe(group.description, () => {
for (const test of group.tests) {
const known = KNOWN_FAILURES.has(`${file.name} > ${group.description} > ${test.description}`);
(known ? it.fails : it)(test.description, () => {
const validator = compile(group.schema, options);
expect(validator.check(test.data)).toBe(test.valid);
});
it(`${test.description} (errors agree with check)`, () => {
const validator = compile(group.schema, options);
const errors = validator.errors(test.data);
expect(errors.length === 0).toBe(validator.check(test.data));
});
}
});
}
});
}

View file

@ -0,0 +1,19 @@
Copyright (c) 2012 Julian Berman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -0,0 +1,9 @@
# JSON-Schema-Test-Suite (subset)
From https://github.com/json-schema-org/JSON-Schema-Test-Suite at commit
`3f05891cac5d51ffa068d7076d1372130e00c829`, unmodified, under its MIT license (`LICENSE`).
Only what the drafts this package supports use:
- `tests/draft2020-12/` and `tests/draft7/`, complete (core, `optional/`, `optional/format/`);
- `remotes/`: the documents those tests reference (served as `http://localhost:1234/...` in the tests).

View file

@ -0,0 +1,3 @@
{
"type": "integer"
}

View file

@ -0,0 +1,3 @@
{
"type": "integer"
}

View file

@ -0,0 +1,7 @@
{
"$id": "http://localhost:1234/draft2019-09/dependentRequired.json",
"$schema": "https://json-schema.org/draft/2019-09/schema",
"dependentRequired": {
"foo": ["bar"]
}
}

View file

@ -0,0 +1,7 @@
{
"$id": "http://localhost:1234/draft2019-09/ignore-prefixItems.json",
"$schema": "https://json-schema.org/draft/2019-09/schema",
"prefixItems": [
{"type": "string"}
]
}

View file

@ -0,0 +1,4 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "integer"
}

View file

@ -0,0 +1,4 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "integer"
}

View file

@ -0,0 +1,4 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "integer"
}

View file

@ -0,0 +1,13 @@
{
"$id": "http://localhost:1234/draft2020-12/detached-dynamicref.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"foo": {
"$dynamicRef": "#detached"
},
"detached": {
"$dynamicAnchor": "detached",
"type": "integer"
}
}
}

View file

@ -0,0 +1,13 @@
{
"$id": "http://localhost:1234/draft2020-12/detached-ref.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"foo": {
"$ref": "#detached"
},
"detached": {
"$anchor": "detached",
"type": "integer"
}
}
}

View file

@ -0,0 +1,5 @@
{
"$id": "http://localhost:1234/draft2020-12/real-id-ref-string.json",
"$defs": {"bar": {"type": "string"}},
"$ref": "#/$defs/bar"
}

View file

@ -0,0 +1,21 @@
{
"description": "extendible array",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "http://localhost:1234/draft2020-12/extendible-dynamic-ref.json",
"type": "object",
"properties": {
"elements": {
"type": "array",
"items": {
"$dynamicRef": "#elements"
}
}
},
"required": ["elements"],
"additionalProperties": false,
"$defs": {
"elements": {
"$dynamicAnchor": "elements"
}
}
}

View file

@ -0,0 +1,13 @@
{
"$id": "http://localhost:1234/draft2020-12/format-assertion-false.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/core": true,
"https://json-schema.org/draft/2020-12/vocab/format-assertion": false
},
"$dynamicAnchor": "meta",
"allOf": [
{ "$ref": "https://json-schema.org/draft/2020-12/meta/core" },
{ "$ref": "https://json-schema.org/draft/2020-12/meta/format-assertion" }
]
}

View file

@ -0,0 +1,13 @@
{
"$id": "http://localhost:1234/draft2020-12/format-assertion-true.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/core": true,
"https://json-schema.org/draft/2020-12/vocab/format-assertion": true
},
"$dynamicAnchor": "meta",
"allOf": [
{ "$ref": "https://json-schema.org/draft/2020-12/meta/core" },
{ "$ref": "https://json-schema.org/draft/2020-12/meta/format-assertion" }
]
}

View file

@ -0,0 +1,4 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "integer"
}

View file

@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"refToInteger": {
"$ref": "#foo"
},
"A": {
"$anchor": "foo",
"type": "integer"
}
}
}

View file

@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "http://localhost:1234/draft2020-12/metaschema-no-validation.json",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/applicator": true,
"https://json-schema.org/draft/2020-12/vocab/core": true
},
"$dynamicAnchor": "meta",
"allOf": [
{ "$ref": "https://json-schema.org/draft/2020-12/meta/applicator" },
{ "$ref": "https://json-schema.org/draft/2020-12/meta/core" }
]
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "http://localhost:1234/draft2020-12/metaschema-optional-vocabulary.json",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/validation": true,
"https://json-schema.org/draft/2020-12/vocab/core": true,
"http://localhost:1234/draft/2020-12/vocab/custom": false
},
"$dynamicAnchor": "meta",
"allOf": [
{ "$ref": "https://json-schema.org/draft/2020-12/meta/validation" },
{ "$ref": "https://json-schema.org/draft/2020-12/meta/core" }
]
}

View file

@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"orNull": {
"anyOf": [
{
"type": "null"
},
{
"$ref": "#"
}
]
}
},
"type": "string"
}

View file

@ -0,0 +1,9 @@
{
"$defs": {
"bar": {
"$id": "http://localhost:1234/draft2020-12/the-nested-id.json",
"type": "string"
}
},
"$ref": "http://localhost:1234/draft2020-12/the-nested-id.json"
}

View file

@ -0,0 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"foo": {"$ref": "string.json"}
}
}

View file

@ -0,0 +1,4 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string"
}

View file

@ -0,0 +1,7 @@
{
"$id": "http://localhost:1234/draft2020-12/prefixItems.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"prefixItems": [
{"type": "string"}
]
}

View file

@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "http://localhost:1234/draft2020-12/ref-and-defs.json",
"$defs": {
"inner": {
"properties": {
"bar": { "type": "string" }
}
}
},
"$ref": "#/$defs/inner"
}

View file

@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"integer": {
"type": "integer"
},
"refToInteger": {
"$ref": "#/$defs/integer"
}
}
}

View file

@ -0,0 +1,17 @@
{
"description": "tree schema, extensible",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "http://localhost:1234/draft2020-12/tree.json",
"$dynamicAnchor": "node",
"type": "object",
"properties": {
"data": true,
"children": {
"type": "array",
"items": {
"$dynamicRef": "#node"
}
}
}
}

View file

@ -0,0 +1,5 @@
{
"$id": "urn:uuid:feebdaed-ffff-0000-2020-1200deadbeef",
"$defs": {"bar": {"type": "string"}},
"$ref": "#/$defs/bar"
}

View file

@ -0,0 +1,13 @@
{
"$id": "http://localhost:1234/draft7/detached-ref.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"foo": {
"$ref": "#detached"
},
"detached": {
"$id": "#detached",
"type": "integer"
}
}
}

View file

@ -0,0 +1,7 @@
{
"$id": "http://localhost:1234/draft7/ignore-dependentRequired.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"dependentRequired": {
"foo": ["bar"]
}
}

View file

@ -0,0 +1,11 @@
{
"definitions": {
"refToInteger": {
"$ref": "#foo"
},
"A": {
"$id": "#foo",
"type": "integer"
}
}
}

View file

@ -0,0 +1,15 @@
{
"definitions": {
"orNull": {
"anyOf": [
{
"type": "null"
},
{
"$ref": "#"
}
]
}
},
"type": "string"
}

View file

@ -0,0 +1,11 @@
{
"$id": "http://localhost:1234/draft7/ref-and-definitions.json",
"definitions": {
"inner": {
"properties": {
"bar": { "type": "string" }
}
}
},
"allOf": [ { "$ref": "#/definitions/inner" } ]
}

View file

@ -0,0 +1,10 @@
{
"definitions": {
"integer": {
"type": "integer"
},
"refToInteger": {
"$ref": "#/definitions/integer"
}
}
}

View file

@ -0,0 +1,3 @@
{
"type": "integer"
}

View file

@ -0,0 +1,6 @@
{
"type": "object",
"properties": {
"foo": {"$ref": "string.json"}
}
}

View file

@ -0,0 +1,3 @@
{
"type": "string"
}

View file

@ -0,0 +1,219 @@
[
{
"description":
"additionalProperties being false does not allow other properties",
"specification": [ { "core":"10.3.2.3", "quote": "The value of \"additionalProperties\" MUST be a valid JSON Schema. Boolean \"false\" forbids everything." } ],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {"foo": {}, "bar": {}},
"patternProperties": { "^v": {} },
"additionalProperties": false
},
"tests": [
{
"description": "no additional properties is valid",
"data": {"foo": 1},
"valid": true
},
{
"description": "an additional property is invalid",
"data": {"foo" : 1, "bar" : 2, "quux" : "boom"},
"valid": false
},
{
"description": "ignores arrays",
"data": [1, 2, 3],
"valid": true
},
{
"description": "ignores strings",
"data": "foobarbaz",
"valid": true
},
{
"description": "ignores other non-objects",
"data": 12,
"valid": true
},
{
"description": "patternProperties are not additional properties",
"data": {"foo":1, "vroom": 2},
"valid": true
}
]
},
{
"description": "non-ASCII pattern with additionalProperties",
"specification": [ { "core":"10.3.2.3"} ],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"patternProperties": {"^á": {}},
"additionalProperties": false
},
"tests": [
{
"description": "matching the pattern is valid",
"data": {"ármányos": 2},
"valid": true
},
{
"description": "not matching the pattern is invalid",
"data": {"élmény": 2},
"valid": false
}
]
},
{
"description": "additionalProperties with schema",
"specification": [ { "core":"10.3.2.3", "quote": "The value of \"additionalProperties\" MUST be a valid JSON Schema." } ],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {"foo": {}, "bar": {}},
"additionalProperties": {"type": "boolean"}
},
"tests": [
{
"description": "no additional properties is valid",
"data": {"foo": 1},
"valid": true
},
{
"description": "an additional valid property is valid",
"data": {"foo" : 1, "bar" : 2, "quux" : true},
"valid": true
},
{
"description": "an additional invalid property is invalid",
"data": {"foo" : 1, "bar" : 2, "quux" : 12},
"valid": false
}
]
},
{
"description": "additionalProperties can exist by itself",
"specification": [ { "core":"10.3.2.3", "quote": "With no other applicator applying to object instances. This validates all the instance values irrespective of their property names" } ],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": {"type": "boolean"}
},
"tests": [
{
"description": "an additional valid property is valid",
"data": {"foo" : true},
"valid": true
},
{
"description": "an additional invalid property is invalid",
"data": {"foo" : 1},
"valid": false
}
]
},
{
"description": "additionalProperties are allowed by default",
"specification": [ { "core":"10.3.2.3", "quote": "Omitting this keyword has the same assertion behavior as an empty schema." } ],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {"foo": {}, "bar": {}}
},
"tests": [
{
"description": "additional properties are allowed",
"data": {"foo": 1, "bar": 2, "quux": true},
"valid": true
}
]
},
{
"description": "additionalProperties does not look in applicators",
"specification":[ { "core": "10.2", "quote": "Subschemas of applicator keywords evaluate the instance completely independently such that the results of one such subschema MUST NOT impact the results of sibling subschemas." } ],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{"properties": {"foo": {}}}
],
"additionalProperties": {"type": "boolean"}
},
"tests": [
{
"description": "properties defined in allOf are not examined",
"data": {"foo": 1, "bar": true},
"valid": false
}
]
},
{
"description": "additionalProperties with null valued instance properties",
"specification": [ { "core":"10.3.2.3" } ],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": {
"type": "null"
}
},
"tests": [
{
"description": "allows null values",
"data": {"foo": null},
"valid": true
}
]
},
{
"description": "additionalProperties with propertyNames",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"propertyNames": {
"maxLength": 5
},
"additionalProperties": {
"type": "number"
}
},
"tests": [
{
"description": "Valid against both keywords",
"data": { "apple": 4 },
"valid": true
},
{
"description": "Valid against propertyNames, but not additionalProperties",
"data": { "fig": 2, "pear": "available" },
"valid": false
}
]
},
{
"description": "dependentSchemas with additionalProperties",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {"foo2": {}},
"dependentSchemas": {
"foo" : {},
"foo2": {
"properties": {
"bar": {}
}
}
},
"additionalProperties": false
},
"tests": [
{
"description": "additionalProperties doesn't consider dependentSchemas",
"data": {"foo": ""},
"valid": false
},
{
"description": "additionalProperties can't see bar",
"data": {"bar": ""},
"valid": false
},
{
"description": "additionalProperties can't see bar even when foo2 is present",
"data": {"foo2": "", "bar": ""},
"valid": false
}
]
}
]

View file

@ -0,0 +1,312 @@
[
{
"description": "allOf",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{
"properties": {
"bar": {"type": "integer"}
},
"required": ["bar"]
},
{
"properties": {
"foo": {"type": "string"}
},
"required": ["foo"]
}
]
},
"tests": [
{
"description": "allOf",
"data": {"foo": "baz", "bar": 2},
"valid": true
},
{
"description": "mismatch second",
"data": {"foo": "baz"},
"valid": false
},
{
"description": "mismatch first",
"data": {"bar": 2},
"valid": false
},
{
"description": "wrong type",
"data": {"foo": "baz", "bar": "quux"},
"valid": false
}
]
},
{
"description": "allOf with base schema",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {"bar": {"type": "integer"}},
"required": ["bar"],
"allOf" : [
{
"properties": {
"foo": {"type": "string"}
},
"required": ["foo"]
},
{
"properties": {
"baz": {"type": "null"}
},
"required": ["baz"]
}
]
},
"tests": [
{
"description": "valid",
"data": {"foo": "quux", "bar": 2, "baz": null},
"valid": true
},
{
"description": "mismatch base schema",
"data": {"foo": "quux", "baz": null},
"valid": false
},
{
"description": "mismatch first allOf",
"data": {"bar": 2, "baz": null},
"valid": false
},
{
"description": "mismatch second allOf",
"data": {"foo": "quux", "bar": 2},
"valid": false
},
{
"description": "mismatch both",
"data": {"bar": 2},
"valid": false
}
]
},
{
"description": "allOf simple types",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{"maximum": 30},
{"minimum": 20}
]
},
"tests": [
{
"description": "valid",
"data": 25,
"valid": true
},
{
"description": "mismatch one",
"data": 35,
"valid": false
}
]
},
{
"description": "allOf with boolean schemas, all true",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [true, true]
},
"tests": [
{
"description": "any value is valid",
"data": "foo",
"valid": true
}
]
},
{
"description": "allOf with boolean schemas, some false",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [true, false]
},
"tests": [
{
"description": "any value is invalid",
"data": "foo",
"valid": false
}
]
},
{
"description": "allOf with boolean schemas, all false",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [false, false]
},
"tests": [
{
"description": "any value is invalid",
"data": "foo",
"valid": false
}
]
},
{
"description": "allOf with one empty schema",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{}
]
},
"tests": [
{
"description": "any data is valid",
"data": 1,
"valid": true
}
]
},
{
"description": "allOf with two empty schemas",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{},
{}
]
},
"tests": [
{
"description": "any data is valid",
"data": 1,
"valid": true
}
]
},
{
"description": "allOf with the first empty schema",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{},
{ "type": "number" }
]
},
"tests": [
{
"description": "number is valid",
"data": 1,
"valid": true
},
{
"description": "string is invalid",
"data": "foo",
"valid": false
}
]
},
{
"description": "allOf with the last empty schema",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{ "type": "number" },
{}
]
},
"tests": [
{
"description": "number is valid",
"data": 1,
"valid": true
},
{
"description": "string is invalid",
"data": "foo",
"valid": false
}
]
},
{
"description": "nested allOf, to check validation semantics",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{
"allOf": [
{
"type": "null"
}
]
}
]
},
"tests": [
{
"description": "null is valid",
"data": null,
"valid": true
},
{
"description": "anything non-null is invalid",
"data": 123,
"valid": false
}
]
},
{
"description": "allOf combined with anyOf, oneOf",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [ { "multipleOf": 2 } ],
"anyOf": [ { "multipleOf": 3 } ],
"oneOf": [ { "multipleOf": 5 } ]
},
"tests": [
{
"description": "allOf: false, anyOf: false, oneOf: false",
"data": 1,
"valid": false
},
{
"description": "allOf: false, anyOf: false, oneOf: true",
"data": 5,
"valid": false
},
{
"description": "allOf: false, anyOf: true, oneOf: false",
"data": 3,
"valid": false
},
{
"description": "allOf: false, anyOf: true, oneOf: true",
"data": 15,
"valid": false
},
{
"description": "allOf: true, anyOf: false, oneOf: false",
"data": 2,
"valid": false
},
{
"description": "allOf: true, anyOf: false, oneOf: true",
"data": 10,
"valid": false
},
{
"description": "allOf: true, anyOf: true, oneOf: false",
"data": 6,
"valid": false
},
{
"description": "allOf: true, anyOf: true, oneOf: true",
"data": 30,
"valid": true
}
]
}
]

View file

@ -0,0 +1,120 @@
[
{
"description": "Location-independent identifier",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$ref": "#foo",
"$defs": {
"A": {
"$anchor": "foo",
"type": "integer"
}
}
},
"tests": [
{
"data": 1,
"description": "match",
"valid": true
},
{
"data": "a",
"description": "mismatch",
"valid": false
}
]
},
{
"description": "Location-independent identifier with absolute URI",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$ref": "http://localhost:1234/draft2020-12/bar#foo",
"$defs": {
"A": {
"$id": "http://localhost:1234/draft2020-12/bar",
"$anchor": "foo",
"type": "integer"
}
}
},
"tests": [
{
"data": 1,
"description": "match",
"valid": true
},
{
"data": "a",
"description": "mismatch",
"valid": false
}
]
},
{
"description": "Location-independent identifier with base URI change in subschema",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "http://localhost:1234/draft2020-12/root",
"$ref": "http://localhost:1234/draft2020-12/nested.json#foo",
"$defs": {
"A": {
"$id": "nested.json",
"$defs": {
"B": {
"$anchor": "foo",
"type": "integer"
}
}
}
}
},
"tests": [
{
"data": 1,
"description": "match",
"valid": true
},
{
"data": "a",
"description": "mismatch",
"valid": false
}
]
},
{
"description": "same $anchor with different base uri",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "http://localhost:1234/draft2020-12/foobar",
"$defs": {
"A": {
"$id": "child1",
"allOf": [
{
"$id": "child2",
"$anchor": "my_anchor",
"type": "number"
},
{
"$anchor": "my_anchor",
"type": "string"
}
]
}
},
"$ref": "child1#my_anchor"
},
"tests": [
{
"description": "$ref resolves to /$defs/A/allOf/1",
"data": "a",
"valid": true
},
{
"description": "$ref does not resolve to /$defs/A/allOf/0",
"data": 1,
"valid": false
}
]
}
]

View file

@ -0,0 +1,203 @@
[
{
"description": "anyOf",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [
{
"type": "integer"
},
{
"minimum": 2
}
]
},
"tests": [
{
"description": "first anyOf valid",
"data": 1,
"valid": true
},
{
"description": "second anyOf valid",
"data": 2.5,
"valid": true
},
{
"description": "both anyOf valid",
"data": 3,
"valid": true
},
{
"description": "neither anyOf valid",
"data": 1.5,
"valid": false
}
]
},
{
"description": "anyOf with base schema",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"anyOf" : [
{
"maxLength": 2
},
{
"minLength": 4
}
]
},
"tests": [
{
"description": "mismatch base schema",
"data": 3,
"valid": false
},
{
"description": "one anyOf valid",
"data": "foobar",
"valid": true
},
{
"description": "both anyOf invalid",
"data": "foo",
"valid": false
}
]
},
{
"description": "anyOf with boolean schemas, all true",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [true, true]
},
"tests": [
{
"description": "any value is valid",
"data": "foo",
"valid": true
}
]
},
{
"description": "anyOf with boolean schemas, some true",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [true, false]
},
"tests": [
{
"description": "any value is valid",
"data": "foo",
"valid": true
}
]
},
{
"description": "anyOf with boolean schemas, all false",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [false, false]
},
"tests": [
{
"description": "any value is invalid",
"data": "foo",
"valid": false
}
]
},
{
"description": "anyOf complex types",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [
{
"properties": {
"bar": {"type": "integer"}
},
"required": ["bar"]
},
{
"properties": {
"foo": {"type": "string"}
},
"required": ["foo"]
}
]
},
"tests": [
{
"description": "first anyOf valid (complex)",
"data": {"bar": 2},
"valid": true
},
{
"description": "second anyOf valid (complex)",
"data": {"foo": "baz"},
"valid": true
},
{
"description": "both anyOf valid (complex)",
"data": {"foo": "baz", "bar": 2},
"valid": true
},
{
"description": "neither anyOf valid (complex)",
"data": {"foo": 2, "bar": "quux"},
"valid": false
}
]
},
{
"description": "anyOf with one empty schema",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [
{ "type": "number" },
{}
]
},
"tests": [
{
"description": "string is valid",
"data": "foo",
"valid": true
},
{
"description": "number is valid",
"data": 123,
"valid": true
}
]
},
{
"description": "nested anyOf, to check validation semantics",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [
{
"anyOf": [
{
"type": "null"
}
]
}
]
},
"tests": [
{
"description": "null is valid",
"data": null,
"valid": true
},
{
"description": "anything non-null is invalid",
"data": 123,
"valid": false
}
]
}
]

View file

@ -0,0 +1,104 @@
[
{
"description": "boolean schema 'true'",
"schema": true,
"tests": [
{
"description": "number is valid",
"data": 1,
"valid": true
},
{
"description": "string is valid",
"data": "foo",
"valid": true
},
{
"description": "boolean true is valid",
"data": true,
"valid": true
},
{
"description": "boolean false is valid",
"data": false,
"valid": true
},
{
"description": "null is valid",
"data": null,
"valid": true
},
{
"description": "object is valid",
"data": {"foo": "bar"},
"valid": true
},
{
"description": "empty object is valid",
"data": {},
"valid": true
},
{
"description": "array is valid",
"data": ["foo"],
"valid": true
},
{
"description": "empty array is valid",
"data": [],
"valid": true
}
]
},
{
"description": "boolean schema 'false'",
"schema": false,
"tests": [
{
"description": "number is invalid",
"data": 1,
"valid": false
},
{
"description": "string is invalid",
"data": "foo",
"valid": false
},
{
"description": "boolean true is invalid",
"data": true,
"valid": false
},
{
"description": "boolean false is invalid",
"data": false,
"valid": false
},
{
"description": "null is invalid",
"data": null,
"valid": false
},
{
"description": "object is invalid",
"data": {"foo": "bar"},
"valid": false
},
{
"description": "empty object is invalid",
"data": {},
"valid": false
},
{
"description": "array is invalid",
"data": ["foo"],
"valid": false
},
{
"description": "empty array is invalid",
"data": [],
"valid": false
}
]
}
]

View file

@ -0,0 +1,431 @@
[
{
"description": "const validation",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": 2
},
"tests": [
{
"description": "same value is valid",
"data": 2,
"valid": true
},
{
"description": "another value is invalid",
"data": 5,
"valid": false
},
{
"description": "another type is invalid",
"data": "a",
"valid": false
}
]
},
{
"description": "const with object",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": {"foo": "bar", "baz": "bax"}
},
"tests": [
{
"description": "same object is valid",
"data": {"foo": "bar", "baz": "bax"},
"valid": true
},
{
"description": "same object with different property order is valid",
"data": {"baz": "bax", "foo": "bar"},
"valid": true
},
{
"description": "another object is invalid",
"data": {"foo": "bar"},
"valid": false
},
{
"description": "another type is invalid",
"data": [1, 2],
"valid": false
}
]
},
{
"description": "const with array",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": [{ "foo": "bar" }]
},
"tests": [
{
"description": "same array is valid",
"data": [{"foo": "bar"}],
"valid": true
},
{
"description": "another array item is invalid",
"data": [2],
"valid": false
},
{
"description": "array with additional items is invalid",
"data": [1, 2, 3],
"valid": false
}
]
},
{
"description": "const with null",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": null
},
"tests": [
{
"description": "null is valid",
"data": null,
"valid": true
},
{
"description": "not null is invalid",
"data": 0,
"valid": false
}
]
},
{
"description": "const with false does not match 0",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": false
},
"tests": [
{
"description": "false is valid",
"data": false,
"valid": true
},
{
"description": "integer zero is invalid",
"data": 0,
"valid": false
},
{
"description": "float zero is invalid",
"data": 0.0,
"valid": false
}
]
},
{
"description": "const with true does not match 1",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": true
},
"tests": [
{
"description": "true is valid",
"data": true,
"valid": true
},
{
"description": "integer one is invalid",
"data": 1,
"valid": false
},
{
"description": "float one is invalid",
"data": 1.0,
"valid": false
}
]
},
{
"description": "const with [false] does not match [0]",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": [false]
},
"tests": [
{
"description": "[false] is valid",
"data": [false],
"valid": true
},
{
"description": "[0] is invalid",
"data": [0],
"valid": false
},
{
"description": "[0.0] is invalid",
"data": [0.0],
"valid": false
}
]
},
{
"description": "const with [true] does not match [1]",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": [true]
},
"tests": [
{
"description": "[true] is valid",
"data": [true],
"valid": true
},
{
"description": "[1] is invalid",
"data": [1],
"valid": false
},
{
"description": "[1.0] is invalid",
"data": [1.0],
"valid": false
}
]
},
{
"description": "const with {\"a\": false} does not match {\"a\": 0}",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": {"a": false}
},
"tests": [
{
"description": "{\"a\": false} is valid",
"data": {"a": false},
"valid": true
},
{
"description": "{\"a\": 0} is invalid",
"data": {"a": 0},
"valid": false
},
{
"description": "{\"a\": 0.0} is invalid",
"data": {"a": 0.0},
"valid": false
}
]
},
{
"description": "const with {\"a\": true} does not match {\"a\": 1}",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": {"a": true}
},
"tests": [
{
"description": "{\"a\": true} is valid",
"data": {"a": true},
"valid": true
},
{
"description": "{\"a\": 1} is invalid",
"data": {"a": 1},
"valid": false
},
{
"description": "{\"a\": 1.0} is invalid",
"data": {"a": 1.0},
"valid": false
}
]
},
{
"description": "const with 0 does not match other zero-like types",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": 0
},
"tests": [
{
"description": "false is invalid",
"data": false,
"valid": false
},
{
"description": "integer zero is valid",
"data": 0,
"valid": true
},
{
"description": "float zero is valid",
"data": 0.0,
"valid": true
},
{
"description": "empty object is invalid",
"data": {},
"valid": false
},
{
"description": "empty array is invalid",
"data": [],
"valid": false
},
{
"description": "empty string is invalid",
"data": "",
"valid": false
}
]
},
{
"description": "const with 1 does not match true",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": 1
},
"tests": [
{
"description": "true is invalid",
"data": true,
"valid": false
},
{
"description": "integer one is valid",
"data": 1,
"valid": true
},
{
"description": "float one is valid",
"data": 1.0,
"valid": true
}
]
},
{
"description": "const with -2.0 matches integer and float types",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": -2.0
},
"tests": [
{
"description": "integer -2 is valid",
"data": -2,
"valid": true
},
{
"description": "integer 2 is invalid",
"data": 2,
"valid": false
},
{
"description": "float -2.0 is valid",
"data": -2.0,
"valid": true
},
{
"description": "float 2.0 is invalid",
"data": 2.0,
"valid": false
},
{
"description": "float -2.00001 is invalid",
"data": -2.00001,
"valid": false
}
]
},
{
"description": "float and integers are equal up to 64-bit representation limits",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": 9007199254740992
},
"tests": [
{
"description": "integer is valid",
"data": 9007199254740992,
"valid": true
},
{
"description": "integer minus one is invalid",
"data": 9007199254740991,
"valid": false
},
{
"description": "float is valid",
"data": 9007199254740992.0,
"valid": true
},
{
"description": "float minus one is invalid",
"data": 9007199254740991.0,
"valid": false
}
]
},
{
"description": "nul characters in strings",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": "hello\u0000there"
},
"tests": [
{
"description": "match string with nul",
"data": "hello\u0000there",
"valid": true
},
{
"description": "do not match string lacking nul",
"data": "hellothere",
"valid": false
}
]
},
{
"description": "characters with the same visual representation but different codepoint",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": "μ",
"$comment": "U+03BC"
},
"tests": [
{
"description": "character uses the same codepoint",
"data": "μ",
"comment": "U+03BC",
"valid": true
},
{
"description": "character looks the same but uses a different codepoint",
"data": "µ",
"comment": "U+00B5",
"valid": false
}
]
},
{
"description": "characters with the same visual representation, but different number of codepoints",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"const": "ä",
"$comment": "U+00E4"
},
"tests": [
{
"description": "character uses the same codepoint",
"data": "ä",
"comment": "U+00E4",
"valid": true
},
{
"description": "character looks the same but uses combining marks",
"data": "ä",
"comment": "a, U+0308",
"valid": false
}
]
}
]

View file

@ -0,0 +1,176 @@
[
{
"description": "contains keyword validation",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contains": {"minimum": 5}
},
"tests": [
{
"description": "array with item matching schema (5) is valid",
"data": [3, 4, 5],
"valid": true
},
{
"description": "array with item matching schema (6) is valid",
"data": [3, 4, 6],
"valid": true
},
{
"description": "array with two items matching schema (5, 6) is valid",
"data": [3, 4, 5, 6],
"valid": true
},
{
"description": "array without items matching schema is invalid",
"data": [2, 3, 4],
"valid": false
},
{
"description": "empty array is invalid",
"data": [],
"valid": false
},
{
"description": "not array is valid",
"data": {},
"valid": true
}
]
},
{
"description": "contains keyword with const keyword",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contains": { "const": 5 }
},
"tests": [
{
"description": "array with item 5 is valid",
"data": [3, 4, 5],
"valid": true
},
{
"description": "array with two items 5 is valid",
"data": [3, 4, 5, 5],
"valid": true
},
{
"description": "array without item 5 is invalid",
"data": [1, 2, 3, 4],
"valid": false
}
]
},
{
"description": "contains keyword with boolean schema true",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contains": true
},
"tests": [
{
"description": "any non-empty array is valid",
"data": ["foo"],
"valid": true
},
{
"description": "empty array is invalid",
"data": [],
"valid": false
}
]
},
{
"description": "contains keyword with boolean schema false",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contains": false
},
"tests": [
{
"description": "any non-empty array is invalid",
"data": ["foo"],
"valid": false
},
{
"description": "empty array is invalid",
"data": [],
"valid": false
},
{
"description": "non-arrays are valid",
"data": "contains does not apply to strings",
"valid": true
}
]
},
{
"description": "items + contains",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"items": { "multipleOf": 2 },
"contains": { "multipleOf": 3 }
},
"tests": [
{
"description": "matches items, does not match contains",
"data": [ 2, 4, 8 ],
"valid": false
},
{
"description": "does not match items, matches contains",
"data": [ 3, 6, 9 ],
"valid": false
},
{
"description": "matches both items and contains",
"data": [ 6, 12 ],
"valid": true
},
{
"description": "matches neither items nor contains",
"data": [ 1, 5 ],
"valid": false
}
]
},
{
"description": "contains with false if subschema",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contains": {
"if": false,
"else": true
}
},
"tests": [
{
"description": "any non-empty array is valid",
"data": ["foo"],
"valid": true
},
{
"description": "empty array is invalid",
"data": [],
"valid": false
}
]
},
{
"description": "contains with null instance elements",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contains": {
"type": "null"
}
},
"tests": [
{
"description": "allows null items",
"data": [ null ],
"valid": true
}
]
}
]

View file

@ -0,0 +1,131 @@
[
{
"description": "validation of string-encoded content based on media type",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contentMediaType": "application/json"
},
"tests": [
{
"description": "a valid JSON document",
"data": "{\"foo\": \"bar\"}",
"valid": true
},
{
"description": "an invalid JSON document; validates true",
"data": "{:}",
"valid": true
},
{
"description": "ignores non-strings",
"data": 100,
"valid": true
}
]
},
{
"description": "validation of binary string-encoding",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contentEncoding": "base64"
},
"tests": [
{
"description": "a valid base64 string",
"data": "eyJmb28iOiAiYmFyIn0K",
"valid": true
},
{
"description": "an invalid base64 string (% is not a valid character); validates true",
"data": "eyJmb28iOi%iYmFyIn0K",
"valid": true
},
{
"description": "ignores non-strings",
"data": 100,
"valid": true
}
]
},
{
"description": "validation of binary-encoded media type documents",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contentMediaType": "application/json",
"contentEncoding": "base64"
},
"tests": [
{
"description": "a valid base64-encoded JSON document",
"data": "eyJmb28iOiAiYmFyIn0K",
"valid": true
},
{
"description": "a validly-encoded invalid JSON document; validates true",
"data": "ezp9Cg==",
"valid": true
},
{
"description": "an invalid base64 string that is valid JSON; validates true",
"data": "{}",
"valid": true
},
{
"description": "ignores non-strings",
"data": 100,
"valid": true
}
]
},
{
"description": "validation of binary-encoded media type documents with schema",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"contentMediaType": "application/json",
"contentEncoding": "base64",
"contentSchema": { "type": "object", "required": ["foo"], "properties": { "foo": { "type": "string" } } }
},
"tests": [
{
"description": "a valid base64-encoded JSON document",
"data": "eyJmb28iOiAiYmFyIn0K",
"valid": true
},
{
"description": "another valid base64-encoded JSON document",
"data": "eyJib28iOiAyMCwgImZvbyI6ICJiYXoifQ==",
"valid": true
},
{
"description": "an invalid base64-encoded JSON document; validates true",
"data": "eyJib28iOiAyMH0=",
"valid": true
},
{
"description": "an empty object as a base64-encoded JSON document; validates true",
"data": "e30=",
"valid": true
},
{
"description": "an empty array as a base64-encoded JSON document",
"data": "W10=",
"valid": true
},
{
"description": "a validly-encoded invalid JSON document; validates true",
"data": "ezp9Cg==",
"valid": true
},
{
"description": "an invalid base64 string that is valid JSON; validates true",
"data": "{}",
"valid": true
},
{
"description": "ignores non-strings",
"data": 100,
"valid": true
}
]
}
]

Some files were not shown because too many files have changed in this diff Show more