01 · Install and answer
A verdict
One package, one type to read a document with, two questions to ask of an instance. Everything below runs in a project with this package and nothing else.
$ moon add moonbitstack/moonschema
A schema is a Json tree. The package does not parse JSON — that is a caller's
job, and the core library's @json.parse does it.
fn main raise {
let schema = @moonschema.Schema::new(
@json.parse("{ \"type\": \"integer\" }"),
)
println(schema.valid(@json.parse("7")))
println(schema.valid(@json.parse("\"seven\"")))
}
true false
Schema::new reads the document once and raises if it cannot — see
refusals. After that, valid answers a yes or a no for
any instance, and faults answers the same question with the reasons.
Schema::new, and the same schema
validates as many instances as you like.
02 · Reasons
When it says no
A fault is a plain struct with four fields. Everything a message needs is in them: where the instance was, which keyword refused it, what that keyword had to say, and where in the schema the keyword sits.
fn main raise {
let person = @moonschema.Schema::new(
@json.parse(
(
#|{ "type": "object",
#| "properties": { "name": { "type": "string", "minLength": 1 } },
#| "required": ["name"] }
),
),
)
for fault in person.faults(@json.parse("{}")) {
println(fault.to_string())
}
for fault in person.faults(@json.parse("{\"name\": \"\"}")) {
println(fault.to_string())
}
}
the instance: missing name (required at /required) /name: shorter than allowed (minLength at /properties/name/minLength)
| Field | Means |
|---|---|
at | Where in the instance, as a JSON Pointer (RFC 6901). Empty for the instance itself. |
keyword | The keyword that refused it — minLength, required, $ref. |
message | What that keyword had to say, in one clause. |
schema_at | Where in the schema, as a JSON Pointer, following the walk as it went. |
A missing member is a fault of the object, not of the member, so at is the
empty string and to_string calls it the instance.
schema_at records the path the walk took. When the walk followed a reference,
the reference is in the path: a fault reached through $ref is reported at
/properties/port/$ref/maximum, which is the route, not the definition.
valid stops at the first refusal, because that is all a yes or no needs.
faults keeps every reason. Ask for one or the other, not both, and the work
matches the question.
03 · Four ways to say it
Four ways to say it
§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, which is the one to send over a wire.
| 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 |
fn main raise {
let schema = @moonschema.Schema::new(
@json.parse("{ \"type\": \"integer\", \"minimum\": 1 }"),
)
let report = schema.report(@json.parse("0"), output=@moonschema.Output::Basic)
println(report.to_json().stringify(indent=2))
}
{
"valid": false,
"errors": [
{
"valid": false,
"keywordLocation": "/minimum",
"instanceLocation": "",
"error": "below the minimum"
}
]
}
detailed says the same thing here, because there is one reason in one place.
verbose adds what passed, which is the difference worth seeing:
{
"valid": false,
"errors": [
{
"valid": false,
"keywordLocation": "/minimum",
"instanceLocation": "",
"error": "below the minimum"
},
{
"valid": true,
"keywordLocation": "/type",
"instanceLocation": ""
}
]
}
There is no absoluteKeywordLocation in either, and that is not an omission:
§12.3.2 lets it go when the schema names no $id, and this one does not.
An output unit carries two pointers that are one pointer until a reference is crossed.
keywordLocation is the evaluation path and includes the $ref it
went through; absoluteKeywordLocation is the schema location — the resource's
own URI and a pointer into it — and never does. A reader that has to fetch the keyword
needs the second:
let schema = @moonschema.Schema::new(
@json.parse(
(
#|{ "$id": "https://example.test/point",
#| "$defs": { "num": { "type": "integer" } },
#| "$ref": "#/$defs/num" }
),
),
)
let report = schema.report(@json.parse("\"seven\""), output=@moonschema.Output::Basic)
for unit in report.units {
let place = match unit.absolute_at {
Some(found) => found
None => "no location to point at"
}
println("\{unit.keyword_at} -> \{place}")
}
/$ref/type -> https://example.test/point#/$defs/num/type
faults is the
same walk read one more way — the leaves that hold a reason, in the order they were
found.
04 · Six of them
Which version a document is
Draft has six values. Draft::uri prints the $schema
a document would write; Draft::of reads one back.
println(@moonschema.Draft::Draft7.uri())
println(
match @moonschema.Draft::of("http://json-schema.org/draft-07/schema#"[:]) {
Some(d) => d.to_string()
None => "no version"
},
)
println(@moonschema.draft)
http://json-schema.org/draft-07/schema# draft-07 2020-12
Three things decide the version, and the most specific wins: a $schema in the
document, then the draft argument to Schema::new, then the preset
@moonschema.draft. A document that names a version is read at that version
whatever the argument says.
let named = @json.parse(
(
#|{ "$schema": "http://json-schema.org/draft-04/schema#",
#| "maximum": 10, "exclusiveMaximum": true }
),
)
let schema = @moonschema.Schema::new(named, draft=@moonschema.Draft::Draft2020)
println(schema.valid(Json::number(10.0)))
false
That false is draft-04 talking: the flag makes maximum exclusive.
Read at 2020-12 the same document would let ten through, because there the flag is not a
number and asserts nothing. This is why the document has the last word.
A URI this does not know is None, not a guess — including
draft-03, which predates the $ keywords. Both
http and https spellings are accepted, with or without the
trailing #.
v1 is the release after 2020-12, and it answers to two IRIs: https://json-schema.org/v1,
which names the latest release within the version, and https://json-schema.org/v1/2026,
which names that release. Three things change. format asserts without being
asked (see format); propertyDependencies joins the
applicators; and $dynamicRef stops asking that it first resolve to a
$dynamicAnchor of the same name before the dynamic scope takes over:
let chosen = @json.parse(
(
#|{ "propertyDependencies": {
#| "kind": { "card": { "required": ["number"] } } } }
),
)
for draft in [@moonschema.Draft::Draft2020, V1] {
let schema = @moonschema.Schema::new(chosen, draft~)
println("\{draft} \{schema.valid(@json.parse("{\"kind\": \"card\"}"))}")
}
2020-12 true v1 false
At 2020-12 the keyword is one of nobody's, and §6.5 says an unknown keyword asserts
nothing. At v1 it says that a card needs a number, and the instance has none.
05 · Members
Objects
Four keywords read the same object and have to agree on which held each of them:
properties, patternProperties,
additionalProperties and propertyNames. A name that a member of
properties covers belongs to it; a name a pattern matched belongs to the
pattern; only what neither covered is additional.
let headers = @moonschema.Schema::new(
@json.parse(
(
#|{ "properties": { "id": { "type": "string" } },
#| "patternProperties": { "^x-": { "type": "string" } },
#| "additionalProperties": { "type": "integer" },
#| "required": ["id"],
#| "dependentRequired": { "id": ["rev"] } }
),
),
)
for fault in headers.faults(
@json.parse("{\"id\": 7, \"x-trace\": 9, \"n\": true}"),
) {
println(fault.to_string())
}
/id: not of type string (type at /properties/id/type) /x-trace: not of type string (type at /patternProperties/^x-/type) /n: not of type integer (type at /additionalProperties/type) the instance: missing rev (dependentRequired at /dependentRequired/id)
propertyNames applies a schema to the names rather than to the values, and
reports a fault at the member it objected to:
let keys = @moonschema.Schema::new(
@json.parse("{ \"propertyNames\": { \"pattern\": \"^[a-z]+$\" } }"),
)
for fault in keys.faults(@json.parse("{\"User Name\": 1}")) {
println(fault.to_string())
}
/User Name: does not match the pattern (pattern at /propertyNames/pattern)
dependencies is the older spelling of the pair: an array of names is
dependentRequired, a schema is dependentSchemas. A document is
read at the version that has the spelling it uses, which is the whole reason for
naming a version.
06 · Items
Arrays
In 2020-12 a list of schemas for the leading items is prefixItems and
items is the schema for everything after it. contains looks for
matches anywhere, and 2019-09 added minContains and
maxContains to say how many.
let batch = @moonschema.Schema::new(
@json.parse(
(
#|{ "type": "array",
#| "prefixItems": [{ "type": "string" }],
#| "items": { "type": "integer" },
#| "minItems": 2, "uniqueItems": true,
#| "contains": { "const": 0 }, "minContains": 1 }
),
),
)
for fault in batch.faults(@json.parse("[\"a\", 1, 1]")) {
println(fault.to_string())
}
println(batch.valid(@json.parse("[\"a\", 0]")))
the instance: two items are equal (uniqueItems at /uniqueItems) the instance: fewer matching items than required (contains at /contains) true
uniqueItems compares by value, not by spelling: 1 and
1.0 are one item, and two objects are one item when they hold the same members
in any order.
Before 2020-12 the leading list is written items: [...] and the remainder is
additionalItems. Written the old way, items read at 2020-12 is not
a schema and asserts nothing — the versions example prints that
table.
07 · Strings
Text
A length is counted in code points, not bytes and not UTF-16 units: an emoji is one character however much storage it takes. A length is not a grapheme count either — that is a different question, and the specification does not ask it.
let word = @moonschema.Schema::new(
@json.parse("{ \"type\": \"string\", \"minLength\": 2, \"maxLength\": 3 }"),
)
println(word.valid(Json::string("中"))) // false — one code point
println(word.valid(Json::string("a"))) // false — one as well
println(word.valid(Json::string("🇨🇳"))) // true — a flag is two
false true true
A flag emoji is two regional indicators, so it is two code points and fits where one letter would not. That is the specification's arithmetic, not an accident of this library.
pattern is ECMA-262 syntax, and it searches: a match anywhere is a match. The
shorthands \d, \w and \s work, and so do property
escapes such as \p{Letter}, \p{Nd} and
\p{Script=Han}, from Unicode 17.0. The pattern is read by moonregex in its ECMA-262
dialect with the u flag, as §6.3.3 asks.
let letters = @moonschema.Schema::new(
@json.parse("{ \"pattern\": \"^\\\\p{Letter}+$\" }"),
)
println(letters.valid(Json::string("Ünïcode")))
println(letters.valid(Json::string("Ün1code")))
true false
A pattern the engine cannot read, such as a misspelt property name, matches nothing. A schema's own mistake then refuses instances rather than admitting every one of them:
let han = @moonschema.Schema::new(
@json.parse("{ \"pattern\": \"^\\\\p{Script=Han}+$\" }"),
)
println(han.valid(Json::string("中")))
let typo = @moonschema.Schema::new(
@json.parse("{ \"pattern\": \"^\\\\p{Scrpt=Han}+$\" }"),
)
println(typo.valid(Json::string("中")))
true false
08 · Shapes of strings
format
format names a shape for a string, and there are nineteen names, each read
against the RFC that defines it. Whether the keyword asserts is the question, and
before v1 the answer is no unless you ask.
let lenient = @moonschema.Schema::new(@json.parse("{ \"format\": \"date-time\" }"))
println(lenient.valid(@json.parse("\"1990-02-31T15:59:59Z\"")))
let strict = @moonschema.Schema::new(
@json.parse("{ \"format\": \"date-time\" }"),
format=true,
)
println(strict.valid(@json.parse("\"1990-02-31T15:59:59Z\"")))
println(strict.valid(@json.parse("\"1998-12-31T23:59:60Z\"")))
true false true
February has no thirty-first day, and the second line is the one that notices. The third
is a leap second: 23:59:60Z is a time RFC 3339 allows, and
22:59:60Z is not — the :60 belongs to one minute of one day, in
UTC, whatever offset the text carries.
Three things decide whether the keyword asserts, and the most specific wins: the
format argument to Schema::new, then the dialect the document
names — a metaschema that declares the format-assertion vocabulary — then the version,
where v1 asserts and everything before it annotates.
| Group | Names, and the RFC behind each |
|---|---|
| Time | date, time, date-time, duration — RFC 3339 |
| Hosts | hostname, idn-hostname — RFC 1123 and RFC 5890; ipv4, ipv6 — RFC 2673 and RFC 4291 |
email, idn-email — RFC 5321 and RFC 6531 | |
| Resources | uri, uri-reference — RFC 3986; iri, iri-reference — RFC 3987; uri-template — RFC 6570 |
| Values | uuid — RFC 4122; json-pointer — RFC 6901; relative-json-pointer; regex — ECMA-262 |
A name this does not know asserts nothing, because a format is the schema author's word for something and an unknown word is not a refusal. v1 makes the exception: there an unknown format is a schema that cannot be read, since the release made assertion the requirement and an assertion nobody can make is not one to skip.
The hosts go through the derived property of RFC 5892, generated from the published tables
by scripts/gen_idna_classes.py; the mapping, the normalisation and the Punycode
are moonurl's. The calendar is moondate's. The rest is read here, because a format is a
grammar and no library in this family answers it.
09 · Numbers
Numbers
integer means a number with nothing after the decimal point, which is what JSON
leaves it as — 1 and 1.0 are the same number, so both are
integers.
let whole = @moonschema.Schema::new(@json.parse("{ \"type\": \"integer\" }"))
println(whole.valid(@json.parse("1.0")))
println(whole.valid(@json.parse("1.5")))
true false
multipleOf allows the error a binary float carries. 0.0075
divided by 0.0001 is 74.999999999999986 in a double, and calling
that not a multiple would be a fact about the arithmetic rather than about the numbers.
let steps = @moonschema.Schema::new(
@json.parse("{ \"type\": \"number\", \"multipleOf\": 0.0001 }"),
)
println(steps.valid(@json.parse("0.0075")))
println(steps.valid(@json.parse("0.00751")))
true false
The bounds are the plain ones. From draft-06 exclusiveMinimum and
exclusiveMaximum are numbers that stand on their own; at draft-04 they are
flags beside minimum and maximum, and the version decides which
reading applies.
10 · Schemas of schemas
Composition, and what is left over
allOf applies every branch and reports every branch that refused.
anyOf passes when one branch does, so a failing branch is not a fault of the
instance — only every branch failing is. oneOf wants exactly one, and says how
many matched when it did not get what it wanted.
let one = @moonschema.Schema::new(
@json.parse("{ \"oneOf\": [ { \"type\": \"integer\" }, { \"minimum\": 0 } ] }"),
)
for fault in one.faults(Json::number(3.0)) {
println(fault.to_string())
}
the instance: 2 branches validated, not one (oneOf at /oneOf)
if never fails on its own: it picks then or else, and
the reasons come from whichever it picked — so a fault points into /then/… or
/else/…, which says immediately which arm was taken.
let shipping = @moonschema.Schema::new(
@json.parse(
(
#|{ "properties": { "country": {}, "postcode": {} },
#| "if": { "properties": { "country": { "const": "US" } } },
#| "then": { "properties": { "postcode": { "pattern": "^[0-9]{5}$" } } },
#| "else": { "properties": { "postcode": { "minLength": 3 } } } }
),
),
)
for fault in shipping.faults(
@json.parse("{\"country\": \"US\", \"postcode\": \"9410\"}"),
) {
println(fault.to_string())
}
/postcode: does not match the pattern (pattern at /then/properties/postcode/pattern)
unevaluatedProperties is the one that needs a note. It does not know what
properties it can see; it asks what every other keyword in the same schema —
and every in-place applicator it reached — evaluated. A branch of allOf that
validated contributes its members, which is what makes it the way to close a composed
object.
let closed = @moonschema.Schema::new(
@json.parse(
(
#|{ "allOf": [
#| { "properties": { "name": { "type": "string" } } },
#| { "properties": { "age": { "type": "integer" } } } ],
#| "unevaluatedProperties": false }
),
),
)
for fault in closed.faults(
@json.parse("{\"name\": \"Ada\", \"email\": \"a@b.test\"}"),
) {
println(fault.to_string())
}
/email: no instance validates against false (schema at /unevaluatedProperties)
The keyword is schema there because the schema in question is the boolean
false, and false refuses everything. A branch that fails
contributes nothing, so the properties it would have covered stay unevaluated — which is
what the specification asks for and what a naive implementation gets wrong.
11 · Addressing
References
$defs holds schemas to refer to and $ref reaches one by JSON
Pointer. A reference is resolved against the base URI in effect — the document's
$id, or the one a surrounding subschema gave it.
let order = @moonschema.Schema::new(
@json.parse(
(
#|{ "$defs": { "port": { "type": "integer", "maximum": 65535 } },
#| "properties": { "port": { "$ref": "#/$defs/port" } } }
),
),
)
for fault in order.faults(@json.parse("{\"port\": 70000}")) {
println(fault.to_string())
}
/port: above the maximum (maximum at /properties/port/$ref/maximum)
A subschema with its own $id is a resource of its own, and references inside
it resolve against that URI. $anchor names a place inside a document by a plain
word, which is what a hand-written reference usually wants.
let money = @moonschema.Schema::new(
@json.parse(
(
#|{ "$id": "https://example.test/order",
#| "$defs": { "money": { "$id": "money", "type": "object",
#| "properties": { "cents": { "type": "integer" } },
#| "required": ["cents"] } },
#| "properties": { "total": { "$ref": "money" } } }
),
),
)
for fault in money.faults(@json.parse("{\"total\": {\"cents\": \"12.50\"}}")) {
println(fault.to_string())
}
/total/cents: not of type integer (type at /properties/total/$ref/properties/cents/type)
"money" is relative, so it lands beside the document at
https://example.test/money — not inside it. That is RFC 3986 resolution, and it
is the part of references that surprises people.
A reference to another document resolves only when that document is handed over. There is no fetching: the package opens no sockets, so a reference to what it was not given lands nowhere, and landing nowhere is a fault.
let remote = @json.parse("{ \"$ref\": \"https://example.test/int\" }")
let alone = @moonschema.Schema::new(remote)
for fault in alone.faults(Json::number(1.0)) {
println(fault.to_string())
}
let fed = @moonschema.Schema::new(
remote,
remotes={
"https://example.test/int": @json.parse("{ \"type\": \"integer\" }"),
},
)
println(fed.valid(Json::number(1.0)))
the instance: reference resolves to nothing ($ref at /$ref) true
The map is keyed by the URI the reference names. Anything the caller has — a file, a database row, a literal — can be put in it; nothing else can reach the validator.
12 · Your own keywords
Your own keywords
JSON Schema is a language of vocabularies, and a vocabulary is a set of keywords with a URI.
One from outside this library plugs in without a change here: a Keyword for the
keyword, a Vocabulary to hold it, and a Registry to offer it.
/// `x-units`: a number is a whole number of units, and the keyword says how many
/// decimal places a unit has — so `2` admits `1.25` and refuses `1.234`.
struct Units {}
pub impl @moonschema.Keyword for Units with fn name(_self) { "x-units" }
pub impl @moonschema.Keyword for Units with fn judge(_self, value, instance, site) {
ignore(site)
match (value, instance) {
(Number(places, ..), Number(number, ..)) => {
let scaled = number * scale_of(places)
let nearest = scaled.round()
let slack = if scaled.abs() > 1.0 { scaled.abs() } else { 1.0 }
if (scaled - nearest).abs() <= 1.0e-9 * slack {
Accept
} else {
Refuse(why="not a whole number of units")
}
}
_ => Accept
}
}
pub extend Units with @moonschema.Keyword::{name, judge}
struct Measures {}
pub impl @moonschema.Vocabulary for Measures with fn uri(_self) {
"https://example.test/vocab/measures"
}
pub impl @moonschema.Vocabulary for Measures with fn keywords(_self) {
let units : &@moonschema.Keyword = Units::{ }
[units]
}
pub extend Measures with @moonschema.Vocabulary::{uri, keywords}
A keyword sees the instance, the keyword's own value, and where both sit. It answers
Accept, Annotate with what it contributes to the output, or
Refuse with a reason. It does not apply subschemas: a keyword that has to walk
one is an applicator, and those are this library's.
What puts the vocabulary in force is the document's own metaschema, read from
remotes like any other document:
let metaschema = @json.parse(
(
#|{ "$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/measures": true } }
),
)
let remotes = { "https://example.test/meta": metaschema }
let registry = @moonschema.Registry::new()
registry.add(Measures::{ })
let schema = @moonschema.Schema::new(
@json.parse(
(
#|{ "$schema": "https://example.test/meta",
#| "type": "number", "x-units": 2 }
),
),
remotes~,
registry~,
)
for text in ["1.25", "1.234"] {
println("\{text} \{schema.valid(@json.parse(text))}")
}
1.25 true 1.234 false
Four rules, and each of them is a refusal rather than a quiet choice:
-
A vocabulary the metaschema names with
trueasserts; named withfalse, its keywords annotate and assert nothing, which is what the specification means by an optional vocabulary. -
A vocabulary named with
truethat nobody holds is a document this cannot read:Schema::newraisesBrokenrather than half-read it. - A keyword may not take a name a built-in keyword answers to. The registration is refused, because a keyword that means one thing here and another to the document is worse than one that does not load.
- Two vocabularies may not claim one name either: the second registration is refused rather than resolved in someone's favour.
13 · Not reading
Refusals
Schema::new raises Refused when it cannot make sense of a
document. Today that is a $schema naming a version nobody here knows:
let refused = try {
@moonschema.Schema::new(
@json.parse("{ \"$schema\": \"https://example.test/v9\" }"),
) |> ignore
"read it anyway"
} catch {
@moonschema.Unknown(uri) => "refused " + uri
_ => "another refusal"
}
println(refused)
refused https://example.test/v9
This is the difference between a fault and a refusal, and it is worth keeping straight: a refusal is a schema this cannot read, a fault is an instance that does not conform. Guessing at a version would turn the second into the first without saying so.
A boolean is a schema too. true admits everything and false
refuses everything, which is how a branch is closed or opened without a keyword:
println(@moonschema.Schema::new(Json::boolean(true)).valid(Json::null())) println(@moonschema.Schema::new(Json::boolean(false)).valid(Json::null()))
true false
14 · Scope
Where it stops
A schema is read for what it says, and not checked against its own metaschema: a schema that is wrong in a way only the metaschema would notice is read as it is written. Reading it for what it says and then refusing to read it at all are different jobs, and only the first is here.
verbose keeps every result this walk kept, which is every keyword it applied —
but a branch of anyOf that failed while a sibling carried the instance is not
kept, because it is not a result. The specification's dropped annotations would show it, and
those are not here.
Three more things are deliberately outside: hyper-schema, which is a different specification; fetching, for the reason given under references; and compiling a schema to code, which is a decision to make when there is a measurement, not before.
The four output forms of the specification have names here — Flag,
Basic, Detailed, Verbose — and two of them are
produced: valid is the flag, faults the basic, flattened. The
preset @moonschema.output is Flag, because the common case is a
guard that wants a yes or a no.