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