Skip to content

About

JSON Schema validation for MoonBit, draft-04 to 2020-12.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

moonschema

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.

Home · Guide · API · Showcase · mooncakes

moon add moonbitstack/moonschema
fn 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)",
  )
}

What it answers

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.

CallAnswers
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

A fault

Four fields, so a message can point at both ends of a refusal.

FieldHolds
atwhere in the instance, as a JSON Pointer (RFC 6901); empty for the instance itself
schema_atwhere in the schema the keyword sits, following the walk it took
keywordthe keyword that refused it — minimum, required, $ref
messagewhat that keyword had to say, in one clause

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.

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

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.

GroupNames
Timedate, time, date-time, duration
Hostshostname, idn-hostname, ipv4, ipv6
Mailemail, idn-email
Resourcesuri, uri-reference, iri, iri-reference, uri-template
Valuesuuid, json-pointer, relative-json-pointer, regex

Versions

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$schemaVersion
Draft4json-schema.org/draft-04/schema#draft-04 (2013)
Draft6json-schema.org/draft-06/schema#draft-06 (2017)
Draft7json-schema.org/draft-07/schema#draft-07 (2018)
Draft2019json-schema.org/draft/2019-09/schema2019-09
Draft2020json-schema.org/draft/2020-12/schema2020-12 — the preset
V1json-schema.org/v1v1 (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.

References

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)

Your own keywords

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)",
)

What it will not do

  • Fetch a remote $ref. The seam is remotes; 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. $schema picks 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.

Conformance

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:

SetCasesFailed
draft46180
draft68410
draft79290
draft2019-091,2610
draft2020-121,3010
v11,1350
v1/proposals/propertyDependencies380
draft4/optional/format2910
draft6/optional/format4070
draft7/optional/format7850
draft2019-09/optional/format8660
draft2020-12/optional/format8660
v1/format8600
draft2020-12/optional/format-assertion.json40
v1/optional/format-annotation.json1330
outputs: draft2019-0940
outputs: draft2020-1240
differential, against two other implementations6760
Total11,0190

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.

Examples

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.

ExampleShows
toura schema, three instances, and the faults they earn
versionsone document read at each of the six, and where they differ
references$defs, $id, $anchor, and a document handed in
compositionallOf, oneOf, if/then/else, and what is left over
formatsone failure in all four output formats
vocabulariesa keyword from outside, put in force by a metaschema
webthe engine the documentation site runs in the browser

Licence

Apache-2.0.

About

JSON Schema validation for MoonBit, draft-04 to 2020-12.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

Generated from moonbitstack/moonkit