moonschema JSON Schema · MoonBit

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.

reading
A schema is read, not compiled: keywords are looked at as the instance walks past them. The cost of reading is paid once, by 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)
FieldMeans
atWhere in the instance, as a JSON Pointer (RFC 6901). Empty for the instance itself.
keywordThe keyword that refused it — minLength, required, $ref.
messageWhat that keyword had to say, in one clause.
schema_atWhere 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.

note
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.

FormatSays
Flagwhether it validated, and nothing else
Basica flat list, one entry per keyword, each with both locations
Detaileda tree, with the places several reasons share kept and a single reason pulled up to its parent
Verbosethe 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
reading
The units come out in pointer order, not in the order the map of keywords happens to hold them: two runs of one validation should read the same. 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.

GroupNames, and the RFC behind each
Timedate, time, date-time, duration — RFC 3339
Hostshostname, idn-hostname — RFC 1123 and RFC 5890; ipv4, ipv6 — RFC 2673 and RFC 4291
Mailemail, idn-email — RFC 5321 and RFC 6531
Resourcesuri, uri-reference — RFC 3986; iri, iri-reference — RFC 3987; uri-template — RFC 6570
Valuesuuid — 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 true asserts; named with false, its keywords annotate and assert nothing, which is what the specification means by an optional vocabulary.
  • A vocabulary named with true that nobody holds is a document this cannot read: Schema::new raises Broken rather 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.