mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
7ef8680abd
commit
fab2c5353e
255 changed files with 37963 additions and 2 deletions
9
packages/schema/CHANGELOG.md
Normal file
9
packages/schema/CHANGELOG.md
Normal 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.
|
||||
23
packages/schema/LICENSES/typebox-LICENSE
Normal file
23
packages/schema/LICENSES/typebox-LICENSE
Normal 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
178
packages/schema/README.md
Normal 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/`.
|
||||
56
packages/schema/package.json
Normal file
56
packages/schema/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
208
packages/schema/src/annotations.ts
Normal file
208
packages/schema/src/annotations.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
187
packages/schema/src/builders.ts
Normal file
187
packages/schema/src/builders.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
446
packages/schema/src/checks.ts
Normal file
446
packages/schema/src/checks.ts
Normal 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;
|
||||
}
|
||||
618
packages/schema/src/compiler.ts
Normal file
618
packages/schema/src/compiler.ts
Normal 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) : [];
|
||||
}
|
||||
327
packages/schema/src/explain.ts
Normal file
327
packages/schema/src/explain.ts
Normal 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;
|
||||
}
|
||||
46
packages/schema/src/formats/index.ts
Normal file
46
packages/schema/src/formats/index.ts
Normal 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,
|
||||
});
|
||||
47
packages/schema/src/formats/typebox/date.ts
Normal file
47
packages/schema/src/formats/typebox/date.ts
Normal 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]);
|
||||
}
|
||||
40
packages/schema/src/formats/typebox/date_time.ts
Normal file
40
packages/schema/src/formats/typebox/date_time.ts
Normal 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]);
|
||||
}
|
||||
38
packages/schema/src/formats/typebox/duration.ts
Normal file
38
packages/schema/src/formats/typebox/duration.ts
Normal 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);
|
||||
}
|
||||
38
packages/schema/src/formats/typebox/email.ts
Normal file
38
packages/schema/src/formats/typebox/email.ts
Normal 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);
|
||||
}
|
||||
39
packages/schema/src/formats/typebox/hostname.ts
Normal file
39
packages/schema/src/formats/typebox/hostname.ts
Normal 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);
|
||||
}
|
||||
38
packages/schema/src/formats/typebox/idn_email.ts
Normal file
38
packages/schema/src/formats/typebox/idn_email.ts
Normal 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"));
|
||||
}
|
||||
40
packages/schema/src/formats/typebox/idn_hostname.ts
Normal file
40
packages/schema/src/formats/typebox/idn_hostname.ts
Normal 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);
|
||||
}
|
||||
127
packages/schema/src/formats/typebox/idna/format/bidi.ts
Normal file
127
packages/schema/src/formats/typebox/idna/format/bidi.ts
Normal 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;
|
||||
}
|
||||
167
packages/schema/src/formats/typebox/idna/format/puny.ts
Normal file
167
packages/schema/src/formats/typebox/idna/format/puny.ts
Normal 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("");
|
||||
}
|
||||
42
packages/schema/src/formats/typebox/idna/hostname.ts
Normal file
42
packages/schema/src/formats/typebox/idna/hostname.ts
Normal 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));
|
||||
}
|
||||
72
packages/schema/src/formats/typebox/idna/idn-hostname.ts
Normal file
72
packages/schema/src/formats/typebox/idna/idn-hostname.ts
Normal 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)));
|
||||
}
|
||||
30
packages/schema/src/formats/typebox/idna/index.ts
Normal file
30
packages/schema/src/formats/typebox/idna/index.ts
Normal 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";
|
||||
40
packages/schema/src/formats/typebox/idna/label/ascii.ts
Normal file
40
packages/schema/src/formats/typebox/idna/label/ascii.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
53
packages/schema/src/formats/typebox/idna/label/puny.ts
Normal file
53
packages/schema/src/formats/typebox/idna/label/puny.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
122
packages/schema/src/formats/typebox/idna/label/unicode.ts
Normal file
122
packages/schema/src/formats/typebox/idna/label/unicode.ts
Normal 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;
|
||||
}
|
||||
116
packages/schema/src/formats/typebox/idna/pattern/pattern.ts
Normal file
116
packages/schema/src/formats/typebox/idna/pattern/pattern.ts
Normal 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",
|
||||
);
|
||||
37
packages/schema/src/formats/typebox/ipv4.ts
Normal file
37
packages/schema/src/formats/typebox/ipv4.ts
Normal 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);
|
||||
}
|
||||
38
packages/schema/src/formats/typebox/ipv6.ts
Normal file
38
packages/schema/src/formats/typebox/ipv6.ts
Normal 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);
|
||||
}
|
||||
63
packages/schema/src/formats/typebox/iri.ts
Normal file
63
packages/schema/src/formats/typebox/iri.ts
Normal 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));
|
||||
}
|
||||
42
packages/schema/src/formats/typebox/iri_reference.ts
Normal file
42
packages/schema/src/formats/typebox/iri_reference.ts
Normal 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");
|
||||
}
|
||||
38
packages/schema/src/formats/typebox/json_pointer.ts
Normal file
38
packages/schema/src/formats/typebox/json_pointer.ts
Normal 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);
|
||||
}
|
||||
40
packages/schema/src/formats/typebox/regex.ts
Normal file
40
packages/schema/src/formats/typebox/regex.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
38
packages/schema/src/formats/typebox/relative_json_pointer.ts
Normal file
38
packages/schema/src/formats/typebox/relative_json_pointer.ts
Normal 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);
|
||||
}
|
||||
56
packages/schema/src/formats/typebox/time.ts
Normal file
56
packages/schema/src/formats/typebox/time.ts
Normal 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;
|
||||
}
|
||||
38
packages/schema/src/formats/typebox/uri.ts
Normal file
38
packages/schema/src/formats/typebox/uri.ts
Normal 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);
|
||||
}
|
||||
38
packages/schema/src/formats/typebox/uri_reference.ts
Normal file
38
packages/schema/src/formats/typebox/uri_reference.ts
Normal 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);
|
||||
}
|
||||
40
packages/schema/src/formats/typebox/uri_template.ts
Normal file
40
packages/schema/src/formats/typebox/uri_template.ts
Normal 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);
|
||||
}
|
||||
38
packages/schema/src/formats/typebox/uuid.ts
Normal file
38
packages/schema/src/formats/typebox/uuid.ts
Normal 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);
|
||||
}
|
||||
31
packages/schema/src/index.ts
Normal file
31
packages/schema/src/index.ts
Normal 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";
|
||||
152
packages/schema/src/resolver.ts
Normal file
152
packages/schema/src/resolver.ts
Normal 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);
|
||||
}
|
||||
50
packages/schema/src/shared.ts
Normal file
50
packages/schema/src/shared.ts
Normal 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 };
|
||||
}
|
||||
36
packages/schema/src/types.ts
Normal file
36
packages/schema/src/types.ts
Normal 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;
|
||||
58
packages/schema/src/validator.ts
Normal file
58
packages/schema/src/validator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
packages/schema/test/bench/all.ts
Normal file
36
packages/schema/test/bench/all.ts
Normal 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}`);
|
||||
101
packages/schema/test/bench/candidates.ts
Normal file
101
packages/schema/test/bench/candidates.ts
Normal 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]),
|
||||
);
|
||||
261
packages/schema/test/bench/cases.ts
Normal file
261
packages/schema/test/bench/cases.ts
Normal 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 },
|
||||
];
|
||||
54
packages/schema/test/bench/conformance.ts
Normal file
54
packages/schema/test/bench/conformance.ts
Normal 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"));
|
||||
122
packages/schema/test/bench/corpus.ts
Normal file
122
packages/schema/test/bench/corpus.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
73
packages/schema/test/bench/memory.ts
Normal file
73
packages/schema/test/bench/memory.ts
Normal 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) }),
|
||||
);
|
||||
}
|
||||
51
packages/schema/test/bench/setup.ts
Normal file
51
packages/schema/test/bench/setup.ts
Normal 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"));
|
||||
43
packages/schema/test/bench/size.ts
Normal file
43
packages/schema/test/bench/size.ts
Normal 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"));
|
||||
62
packages/schema/test/bench/speed.ts
Normal file
62
packages/schema/test/bench/speed.ts
Normal 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`;
|
||||
}
|
||||
104
packages/schema/test/builders.test.ts
Normal file
104
packages/schema/test/builders.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
51
packages/schema/test/conformance.test.ts
Normal file
51
packages/schema/test/conformance.test.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
19
packages/schema/test/fixtures/json-schema-test-suite/LICENSE
vendored
Normal file
19
packages/schema/test/fixtures/json-schema-test-suite/LICENSE
vendored
Normal 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.
|
||||
9
packages/schema/test/fixtures/json-schema-test-suite/README.md
vendored
Normal file
9
packages/schema/test/fixtures/json-schema-test-suite/README.md
vendored
Normal 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).
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "integer"
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "integer"
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "integer"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$id": "http://localhost:1234/draft2019-09/dependentRequired.json",
|
||||
"$schema": "https://json-schema.org/draft/2019-09/schema",
|
||||
"dependentRequired": {
|
||||
"foo": ["bar"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "integer"
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "integer"
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "integer"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/detached-ref.json
vendored
Normal file
13
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/detached-ref.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$id": "http://localhost:1234/draft2020-12/real-id-ref-string.json",
|
||||
"$defs": {"bar": {"type": "string"}},
|
||||
"$ref": "#/$defs/bar"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
4
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/integer.json
vendored
Normal file
4
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/integer.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "integer"
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$defs": {
|
||||
"refToInteger": {
|
||||
"$ref": "#foo"
|
||||
},
|
||||
"A": {
|
||||
"$anchor": "foo",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
16
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/name-defs.json
vendored
Normal file
16
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/name-defs.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$defs": {
|
||||
"orNull": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foo": {"$ref": "string.json"}
|
||||
}
|
||||
}
|
||||
4
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/nested/string.json
vendored
Normal file
4
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/nested/string.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "string"
|
||||
}
|
||||
7
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/prefixItems.json
vendored
Normal file
7
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/prefixItems.json
vendored
Normal 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"}
|
||||
]
|
||||
}
|
||||
12
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/ref-and-defs.json
vendored
Normal file
12
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/ref-and-defs.json
vendored
Normal 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"
|
||||
}
|
||||
11
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/subSchemas.json
vendored
Normal file
11
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/subSchemas.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$defs": {
|
||||
"integer": {
|
||||
"type": "integer"
|
||||
},
|
||||
"refToInteger": {
|
||||
"$ref": "#/$defs/integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/tree.json
vendored
Normal file
17
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft2020-12/tree.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$id": "urn:uuid:feebdaed-ffff-0000-2020-1200deadbeef",
|
||||
"$defs": {"bar": {"type": "string"}},
|
||||
"$ref": "#/$defs/bar"
|
||||
}
|
||||
13
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft7/detached-ref.json
vendored
Normal file
13
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft7/detached-ref.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$id": "http://localhost:1234/draft7/ignore-dependentRequired.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"dependentRequired": {
|
||||
"foo": ["bar"]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"definitions": {
|
||||
"refToInteger": {
|
||||
"$ref": "#foo"
|
||||
},
|
||||
"A": {
|
||||
"$id": "#foo",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft7/name.json
vendored
Normal file
15
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft7/name.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"definitions": {
|
||||
"orNull": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "string"
|
||||
}
|
||||
11
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft7/ref-and-definitions.json
vendored
Normal file
11
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft7/ref-and-definitions.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$id": "http://localhost:1234/draft7/ref-and-definitions.json",
|
||||
"definitions": {
|
||||
"inner": {
|
||||
"properties": {
|
||||
"bar": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [ { "$ref": "#/definitions/inner" } ]
|
||||
}
|
||||
10
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft7/subSchemas.json
vendored
Normal file
10
packages/schema/test/fixtures/json-schema-test-suite/remotes/draft7/subSchemas.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"definitions": {
|
||||
"integer": {
|
||||
"type": "integer"
|
||||
},
|
||||
"refToInteger": {
|
||||
"$ref": "#/definitions/integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/schema/test/fixtures/json-schema-test-suite/remotes/integer.json
vendored
Normal file
3
packages/schema/test/fixtures/json-schema-test-suite/remotes/integer.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "integer"
|
||||
}
|
||||
6
packages/schema/test/fixtures/json-schema-test-suite/remotes/nested/foo-ref-string.json
vendored
Normal file
6
packages/schema/test/fixtures/json-schema-test-suite/remotes/nested/foo-ref-string.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foo": {"$ref": "string.json"}
|
||||
}
|
||||
}
|
||||
3
packages/schema/test/fixtures/json-schema-test-suite/remotes/nested/string.json
vendored
Normal file
3
packages/schema/test/fixtures/json-schema-test-suite/remotes/nested/string.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "string"
|
||||
}
|
||||
219
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/additionalProperties.json
vendored
Normal file
219
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/additionalProperties.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
312
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/allOf.json
vendored
Normal file
312
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/allOf.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
120
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/anchor.json
vendored
Normal file
120
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/anchor.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
203
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/anyOf.json
vendored
Normal file
203
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/anyOf.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
104
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/boolean_schema.json
vendored
Normal file
104
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/boolean_schema.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
431
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/const.json
vendored
Normal file
431
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/const.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
176
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/contains.json
vendored
Normal file
176
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/contains.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
131
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/content.json
vendored
Normal file
131
packages/schema/test/fixtures/json-schema-test-suite/tests/draft2020-12/content.json
vendored
Normal 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
Loading…
Add table
Add a link
Reference in a new issue