JSON Schema validation for MoonBit.
A schema is read, an instance is walked against it, and the answer says where —
in the instance, and in the schema. Six versions of the specification, one validator, no sockets.
moon add moonbitstack/moonschemafn main raise {
let schema = @moonschema.Schema::new(
@json.loads(
(
#|{ "type": "object",
#| "properties": { "port": { "type": "integer", "minimum": 1 } },
#| "required": ["port"] }
),
),
)
assert_eq(schema.valid(@json.loads("{\"port\": 8080}")), true)
let faults = schema.faults(@json.loads("{\"port\": 0}"))
assert_eq(faults.length(), 1)
assert_eq(
faults[0].to_string(),
"/port: below the minimum (minimum at /properties/port/minimum)",
)
}Schema::new reads the document once. After that, valid is the flag output, faults is the
flat list of reasons, and report is any of the four formats the specification defines.
| Call | Answers |
|---|---|
Schema::new(document) | a schema, read at the version it names; raises Refused when it cannot be read |
schema.valid(instance) | Bool — whether the instance conforms, and nothing else |
schema.faults(instance) | Array[Fault] — every reason it did not, empty when it did |
schema.report(instance, output=…) | Report — the four formats of §12, and to_json() to write one |
Schema::new(doc, draft=…) | the version to read a document that names none |
Schema::new(doc, remotes=…) | the documents a $ref may reach, by URI |
Schema::new(doc, format=true) | make format assert, or false to leave it an annotation |
Schema::new(doc, registry=…) | the vocabularies from outside this library a document may ask for |
Four fields, so a message can point at both ends of a refusal.
| Field | Holds |
|---|---|
at | where in the instance, as a JSON Pointer (RFC 6901); empty for the instance itself |
schema_at | where in the schema the keyword sits, following the walk it took |
keyword | the keyword that refused it — minimum, required, $ref |
message | what that keyword had to say, in one clause |
§12 of the specification names four output formats, and report is all four: the same walk, said
with more or less of itself. Report::to_json writes the object §12.4 describes.
| Format | Says |
|---|---|
Flag | whether it validated, and nothing else |
Basic | a flat list, one entry per keyword, each with both locations |
Detailed | a tree, with the places several reasons share kept and a single reason pulled up to its parent |
Verbose | the whole tree, annotations and passes included |
let schema = @moonschema.Schema::new(@json.loads("{ \"type\": \"integer\" }"))
let report = schema.report(@json.loads("\"seven\""), output=@moonschema.Output::Basic)
assert_eq(
report.to_json().stringify(indent=2),
#|{
#| "valid": false,
#| "errors": [
#| {
#| "valid": false,
#| "keywordLocation": "/type",
#| "instanceLocation": "",
#| "error": "not of type integer"
#| }
#| ]
#|},
)An output unit carries two pointers that are one pointer until a reference is crossed:
keywordLocation is the evaluation path and goes through $ref, while
absoluteKeywordLocation is the schema location — the resource's own URI and a pointer into
it — and does not.
format names a shape for a string: date-time, email, ipv4, uri, uuid, regex and a
dozen more, each read against the RFC that defines it. Before v1 the specification makes the
keyword an annotation, so it says nothing about validity unless you ask:
let schema = @moonschema.Schema::new(
@json.loads("{ \"format\": \"ipv4\" }"),
format=true,
)
assert_eq(schema.valid(@json.loads("\"not-an-ipv4\"")), false)
assert_eq(schema.valid(@json.loads("\"10.0.0.1\"")), true)The argument is optional, and leaving it out lets the document answer: an annotation unless its dialect's metaschema declares the format-assertion vocabulary, and an assertion throughout v1, which made assertion the requirement. A call is more specific than a dialect, which is more specific than a version.
| Group | Names |
|---|---|
| Time | date, time, date-time, duration |
| Hosts | hostname, idn-hostname, ipv4, ipv6 |
email, idn-email | |
| Resources | uri, uri-reference, iri, iri-reference, uri-template |
| Values | uuid, json-pointer, relative-json-pointer, regex |
A document is read at the version it names in $schema. The draft argument is the fallback for a
document that names nothing; it never overrides one that does. A $schema this does not know is
refused, not guessed at.
| Value | $schema | Version |
|---|---|---|
Draft4 | json-schema.org/draft-04/schema# | draft-04 (2013) |
Draft6 | json-schema.org/draft-06/schema# | draft-06 (2017) |
Draft7 | json-schema.org/draft-07/schema# | draft-07 (2018) |
Draft2019 | json-schema.org/draft/2019-09/schema | 2019-09 |
Draft2020 | json-schema.org/draft/2020-12/schema | 2020-12 — the preset |
V1 | json-schema.org/v1 | v1 (2026), or …/v1/2026 for the release |
Both http and https spellings are accepted, with or without the trailing #.
Draft::of(uri) reads one back; Draft::uri() prints the one a version writes.
v1 is 2020-12 with three differences: format asserts without being asked, propertyDependencies
joins the applicators — one property's value chooses the subschema the whole instance is then
validated against — and $dynamicRef drops the rule that it first had to resolve to a
$dynamicAnchor of the same name.
A $ref is resolved with the RFC 3986 rules a browser uses for a relative link, against the base
URI in effect — the document's $id, or the one a surrounding subschema gave it. $anchor names a
place inside a document by a plain word.
A reference to another document resolves only when that document is handed over. Nothing is fetched: a validator that opens sockets is a validator that blocks, retries and fails. What is not handed over does not resolve, and a reference that lands nowhere is a fault rather than a silent pass.
let remote = @json.loads("{ \"$ref\": \"https://example.test/int\" }")
let schema = @moonschema.Schema::new(
remote,
remotes={
"https://example.test/int": @json.loads("{ \"type\": \"integer\" }"),
},
)
assert_eq(schema.valid(Json::number(1.0)), true)A vocabulary outside this library plugs in without a change here: a Keyword asserts or
annotates, a Vocabulary holds a set of them under a URI, and a Registry offers them to a
document. The document's own metaschema decides which are in force — named with true they
assert, with false they annotate and assert nothing, and named with true and held by nobody the
document is refused rather than half-read.
struct Even {}
pub impl @moonschema.Keyword for Even with fn name(_self) {
"x-even"
}
pub impl @moonschema.Keyword for Even with fn judge(_self, value, instance, site) {
ignore(site)
match (value, instance) {
(True, Number(number, ..)) =>
if number == (number / 2.0).floor() * 2.0 {
Accept
} else {
Refuse(why="odd")
}
_ => Accept
}
}
pub extend Even with @moonschema.Keyword::{name, judge}
struct Counting {}
pub impl @moonschema.Vocabulary for Counting with fn uri(_self) {
"https://example.test/vocab/even"
}
pub impl @moonschema.Vocabulary for Counting with fn keywords(_self) {
let even : &@moonschema.Keyword = Even::{ }
[even]
}
pub extend Counting with @moonschema.Vocabulary::{uri, keywords}A keyword sees the instance, the keyword's value, and where both sit; it does not apply subschemas. A name a built-in keyword already answers to cannot be taken, and two vocabularies cannot claim one name: the second registration is refused rather than resolved in someone's favour.
What puts the vocabulary in force is the document's own metaschema, handed over like any other document:
let metaschema = @json.loads(
(
#|{ "$id": "https://example.test/meta",
#| "$schema": "https://json-schema.org/draft/2020-12/schema",
#| "$vocabulary": {
#| "https://json-schema.org/draft/2020-12/vocab/validation": true,
#| "https://example.test/vocab/even": true } }
),
)
let remotes = { "https://example.test/meta": metaschema }
let registry = @moonschema.Registry::new()
registry.add(Counting::{ })
let schema = @moonschema.Schema::new(
@json.loads(
(
#|{ "$schema": "https://example.test/meta",
#| "type": "integer", "x-even": true }
),
),
remotes~,
registry~,
)
assert_eq(schema.valid(@json.loads("4")), true)
assert_eq(
schema.faults(@json.loads("5"))[0].to_string(),
"the instance: odd (x-even at /x-even)",
)- Fetch a remote
$ref. The seam isremotes; the caller fills it from wherever they keep them. - Validate a schema against its metaschema. A schema is read for what it says, not checked for
whether it should have said it.
$schemapicks the version; the rest is the schema author's. - Hyper-schema. Links and media types are a different specification.
- Compile schemas to code. Interpretation first; a measurement is the time to argue about it.
The specification has no reference implementation, so it publishes cases instead: one suite covering
every keyword at every version, written by the people who wrote the other validators. All of it runs
here — the required cases at each of the six versions, the optional format sets, the output-format
cases, and the v1 proposals.
A suite is not the whole question, though: it says whether the answers are right, not whether another validator would have given them. So a second run replays a fixed corpus against this library and against two mature implementations — Ajv and python-jsonschema — keeping only the cases where those two agree with each other, so that the corpus is somebody else's opinion and not this library's.
The table is the gate's own output, put into this page by a script so that a count here cannot go stale:
| Set | Cases | Failed |
|---|---|---|
draft4 | 618 | 0 |
draft6 | 841 | 0 |
draft7 | 929 | 0 |
draft2019-09 | 1,261 | 0 |
draft2020-12 | 1,301 | 0 |
v1 | 1,135 | 0 |
v1/proposals/propertyDependencies | 38 | 0 |
draft4/optional/format | 291 | 0 |
draft6/optional/format | 407 | 0 |
draft7/optional/format | 785 | 0 |
draft2019-09/optional/format | 866 | 0 |
draft2020-12/optional/format | 866 | 0 |
v1/format | 860 | 0 |
draft2020-12/optional/format-assertion.json | 4 | 0 |
v1/optional/format-annotation.json | 133 | 0 |
outputs: draft2019-09 | 4 | 0 |
outputs: draft2020-12 | 4 | 0 |
differential, against two other implementations | 676 | 0 |
| Total | 11,019 | 0 |
No case fails. One construct is read differently on purpose: multipleOf with a divisor that is not a
binary fraction. Both references refuse 0.3 against {"multipleOf": 0.1}, because 0.3 / 0.1 is
2.9999999999999996 in binary floating point. The suite requires 0.0075 to be a multiple of
0.0001, which can only hold if the division is the decimal one the document wrote, so this library
allows the remainder the representation leaves behind and takes the suite's side.
Each is a program with its real output, run with moon run src/examples/<name>. The last is the
library the documentation site compiles to JavaScript, built with
moon build --target js --release src/examples/web.
| Example | Shows |
|---|---|
tour | a schema, three instances, and the faults they earn |
versions | one document read at each of the six, and where they differ |
references | $defs, $id, $anchor, and a document handed in |
composition | allOf, oneOf, if/then/else, and what is left over |
formats | one failure in all four output formats |
vocabularies | a keyword from outside, put in force by a metaschema |
web | the engine the documentation site runs in the browser |
Apache-2.0.