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 · The basic output

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 · Five of them

Which version a document is

Draft has five 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 #.

04 · 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.

05 · 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.

06 · 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

07 · 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.

08 · 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.

09 · 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.

10 · 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

11 · Scope

Where it stops

format is an annotation in the specification unless a caller asks otherwise, and this does not offer the otherwise yet — so format asserts nothing and a schema that relies on it is a schema with a hole. Validate the value with pattern, or check it yourself after the fact.

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.