moonschema

    JSON Schema validation for MoonBit, draft-04 through v1: four output formats, nineteen format names, and vocabularies from outside.

    json-schema
    schema
    validation
    openapi
    json
    moonbit
    Download zip
    Version
    0.2.3
    License
    Apache-2.0
    Last updated
    8 hours ago
    Downloads
    924

    moonschema

    moon add moonbitstack/moonschema

    fn main raise {
    let schema = @moonschema.Schema::new(
    @json.parse(
    (
    #|{ "type": "object",
    #| "properties": { "port": { "type": "integer", "minimum": 1 } },
    #| "required": ["port"] }
    ),
    ),
    )
    println(schema.valid(@json.parse("{\"port\": 8080}")))
    for fault in schema.faults(@json.parse("{\"port\": 0}")) {
    println(fault.to_string())
    }
    }

    true /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.

    #A fault

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

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

    let schema = @moonschema.Schema::new(@json.parse("{ \"type\": \"integer\" }"))
    let report = schema.report(@json.parse("\"seven\""), output=@moonschema.Output::Basic)
    println(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.parse("{ \"format\": \"ipv4\" }"),
    format=true,
    )
    println(schema.valid(@json.parse("\"not-an-ipv4\""))) // false

    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.

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

    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.parse("{ \"$ref\": \"https://example.test/int\" }")
    let schema = @moonschema.Schema::new(
    remote,
    remotes={
    "https://example.test/int": @json.parse("{ \"type\": \"integer\" }"),
    },
    )
    println(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/counting"
    }

    pub impl @moonschema.Vocabulary for Counting with fn keywords(_self) {
    let even : &@moonschema.Keyword = Even::{ }
    [even]
    }

    pub extend Counting with 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 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.

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

    #Licence

    Apache-2.0.

    Keyword

    pub(open) trait Keyword {
    fn name(Self) -> String
    fn judge(Self, value : Json, instance : Json, site : Site) -> Verdict
    }

    A keyword someone outside this library defines.

    It is written in a schema as [Keyword::name] and read once per instance, with the keyword's own value, the instance, and where both sit. It asserts, annotates, or both; it does not apply subschemas — a keyword that has to walk a subschema is an applicator, and applicators are this library's own.

    struct Even {}

    pub impl Keyword for Even with fn name(_self) {
    "x-even"
    }

    pub impl Keyword for Even with fn judge(_self, value, instance, site) {
    ignore(site)
    match (value, instance) {
    (True, Number(number, ..)) =>
    if number % 2.0 == 0.0 {
    Accept
    } else {
    Refuse("odd")
    }
    _ => Accept()
    }
    }

    pub extend Even with Keyword::{name, judge}

    Vocabulary

    pub(open) trait Vocabulary {
    fn uri(Self) -> String
    fn keywords(Self) -> Array[&Keyword]
    }

    A set of keywords that a document puts in force by naming its URI in $vocabulary.

    The URI is the one the schema's metaschema uses — a vocabulary is identified by an absolute URI, conventionally one ending in /vocab/<name>. A document whose metaschema names this URI with true gets the keywords; with false, they annotate and assert nothing.

    Refused

    pub(all) suberror Refused {
    Unknown(String)
    Broken(at~ : String, why~ : String)
    } derive(Eq,
    Debug
    )

    Why a schema could not be read.

    These are faults in the schema, not in the instance an instance that fails validation produces [Fault]s instead.
    impl Show for Refused

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::output

    fn Refused::output(self : Refused, logger : &Logger) -> Unit

    Refused::to_repr

    Refused::to_string

    fn Refused::to_string(self : Refused) -> String

    Draft

    pub(all) enum Draft {
    Draft4
    Draft6
    Draft7
    Draft2019
    Draft2020
    V1
    } derive(Eq,
    Debug
    )

    Which version of JSON Schema a document is written against.

    The six released versions that are in use. draft-03 is not here: it predates the $ keywords and nothing has been written against it since 2010. The next release arrives as another value of this enum and a keyword table, not as a second validator.
    impl Show for Draft

    Draft::equal

    fn Draft::equal(Draft, Draft) -> Bool

    Draft::not_equal

    fn Draft::not_equal(x : Draft, y : Draft) -> Bool

    Draft::of

    fn Draft::of(uri : StringView) -> Draft?

    The version a $schema URI names.

    It answers None for a URI it does not know rather than falling back to a version of its own choosing: a document written against something else is not a document to guess at. Both the http and https spellings are accepted, and a trailing # is optional, because both are found in the wild.

    Draft::output

    fn Draft::output(self : Draft, logger : &Logger) -> Unit

    Draft::to_repr

    Draft::to_string

    fn Draft::to_string(self : Draft) -> String

    Draft::uri

    fn Draft::uri(self : Draft) -> String

    The $schema URI that names this version.

    Fault

    pub(all) struct Fault {
    at : String
    schema_at : String
    keyword : String
    message : String
    } derive(Eq,
    Debug
    )

    One reason an instance failed, in the shape the basic output gives it (2020-12 §12.4.2).
    impl Show for Fault

    Fault::equal

    fn Fault::equal(Fault, Fault) -> Bool

    Fault::not_equal

    fn Fault::not_equal(x : Fault, y : Fault) -> Bool

    Fault::output

    fn Fault::output(self : Fault, logger : &Logger) -> Unit

    Fault::to_repr

    Fault::to_string

    fn Fault::to_string(self : Fault) -> String

    Output

    pub(all) enum Output {
    Flag
    Basic
    Detailed
    Verbose
    } derive(Eq,
    Debug
    )

    How much a validation says when it fails.

    The four formats the specification itself defines (2020-12 §12.4), named as it names them.
    impl Show for Output

    Output::equal

    fn Output::equal(Output, Output) -> Bool

    Output::not_equal

    fn Output::not_equal(x : Output, y : Output) -> Bool

    Output::output

    fn Output::output(self : Output, logger : &Logger) -> Unit

    Output::to_repr

    Output::to_string

    fn Output::to_string(self : Output) -> String

    OutputUnit

    pub(all) struct OutputUnit {
    valid : Bool
    keyword_at : String
    absolute_at : String?
    at : String
    error : String?
    annotation : Json?
    children : Array[OutputUnit]
    } derive(Eq,
    Debug
    )

    One result of one keyword: the output unit of §12.3.

    keyword_at is the evaluation path, which is a JSON Pointer that goes through $ref; absolute_at is the schema location, which is the resource's own URI and a pointer into it and never goes through one. The two differ exactly when a reference was crossed, and a reader that has to fetch the keyword needs the second.

    OutputUnit::equal

    fn OutputUnit::equal(OutputUnit, OutputUnit) -> Bool

    OutputUnit::not_equal

    fn OutputUnit::not_equal(x : OutputUnit, y : OutputUnit) -> Bool

    OutputUnit::to_json

    fn OutputUnit::to_json(self : OutputUnit) -> Json

    Registry

    pub struct Registry {
    // private fields
    }

    The vocabularies a caller can offer a document, by URI.

    A registry is not global: two schemas in one program may be read against different vocabularies, and a schema is read against what its own caller handed it. [registry] is the empty one, which is what a caller who adds nothing gets; a caller with vocabularies of their own starts from [Registry::new].

    Registry::add

    fn Registry::add(self : Registry, vocabulary : &Vocabulary) -> Unit raise Refused

    Add a vocabulary.

    It is refused when the vocabulary's URI is already held, when one of its keywords is a name a built-in keyword answers to, or when one of them is a name another vocabulary already took. A clash is refused rather than resolved in someone's favour: a keyword that quietly means one thing to the library and another to the document is worse than one that does not load.

    Registry::new

    fn Registry::new() -> Registry

    A registry that holds nothing, and answers for no name but the built-in ones.

    Report

    pub(all) struct Report {
    valid : Bool
    units : Array[OutputUnit]
    } derive(Eq,
    Debug
    )

    What a validation came to, in the shape the caller asked for.

    units is the whole of it: empty for flag, flat for basic, a pruned tree for detailed, and the tree the walk produced for verbose. [Report::to_json] writes it as §12.4's object, which is what a caller sends over a wire.
    impl ToJson for Report

    Report::equal

    fn Report::equal(Report, Report) -> Bool

    Report::not_equal

    fn Report::not_equal(x : Report, y : Report) -> Bool

    Report::to_json

    fn Report::to_json(self : Report) -> Json

    Report::to_repr

    Schema

    pub struct Schema {
    // private fields
    }

    A schema document, read and ready to validate instances against.

    The version is the one the document names in $schema, whatever draft said; a document is more specific than a setting. remotes are the documents a $ref may reach outside this one, by the URI each is registered under: this library opens no sockets, so what is not fed to it does not exist.

    Schema::faults

    fn Schema::faults(self : Schema, instance : Json) -> Array[Fault]

    Every reason instance failed, or an empty array when it validated.

    The reasons, and not the shape of the walk: each fault says where in the instance it was, which keyword refused it, and where that keyword was. [Schema::report] is the other reading of the same walk, in the four formats §12 names.

    let schema = @moonschema.Schema::new(
    @json.parse("{ \"type\": \"integer\", \"minimum\": 1 }"),
    )
    for fault in schema.faults(@json.parse("0")) {
    println(fault.to_string())
    }
    // the instance: below the minimum (minimum at /minimum)

    Schema::new

    fn Schema::new(document : Json, draft? : Draft, remotes? : Map[String, Json], format? : Bool, registry? : Registry) -> Schema raise Refused

    Read a schema document.

    draft is the version to read it at when it does not name one; a $schema it does name wins. remotes are the documents a $ref may reach, each under the URI it is referred to by.

    registry holds the vocabularies a document may ask for beyond the built-in ones: a $vocabulary naming one of them puts its keywords in force, naming one nobody holds with true refuses the document, and naming it with false leaves its keywords as annotations.

    format decides whether format asserts. Left out, the document answers for itself: an annotation before v1 unless its dialect's metaschema declares the assertion vocabulary, and an assertion throughout v1. Given, it is the answer, because a call is more specific than a document's dialect, which is more specific than a version's default.

    let schema = @moonschema.Schema::new(
    @json.parse("{ \"type\": \"object\", \"required\": [\"id\"] }"),
    )
    schema.valid(@json.parse("{\"id\": 1}")) // true

    Schema::report

    fn Schema::report(self : Schema, instance : Json, output? : Output) -> Report

    Validate instance and answer in the format output names.

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

    Schema::valid

    fn Schema::valid(self : Schema, instance : Json) -> Bool

    Whether instance validates.

    This is the flag output (§12.4.1): the cheapest answer, and the one a guard on a request wants. [Schema::faults] says why not.

    let schema = @moonschema.Schema::new(@json.parse("{ \"type\": \"integer\" }"))
    schema.valid(@json.parse("7")) // true
    schema.valid(@json.parse("\"seven\"")) // false

    Site

    pub(all) struct Site {
    at : String
    schema_at : String
    } derive(Eq,
    Debug
    )

    Where a keyword sits, for the benefit of a keyword that has to say where it looked.

    Site::equal

    fn Site::equal(Site, Site) -> Bool

    Site::not_equal

    fn Site::not_equal(x : Site, y : Site) -> Bool

    Site::to_repr

    Verdict

    pub(all) enum Verdict {
    Accept
    Annotate(annotation~ : Json)
    Refuse(why~ : String)
    } derive(Eq,
    Debug
    )

    What a keyword says about one instance.

    Verdict::equal

    fn Verdict::equal(Verdict, Verdict) -> Bool

    Verdict::not_equal

    fn Verdict::not_equal(x : Verdict, y : Verdict) -> Bool

    Verdict::to_repr

    draft

    let draft : Draft

    The version assumed when a document does not name one.

    2020-12: the current release, and the one OpenAPI 3.1 is defined against. A document that says $schema is read at the version it says, whatever this is set to — the document is more specific than the setting.

    output

    let output : Output

    The output format a validation produces unless asked for another.

    Flag, because the common case is a guard that wants a yes or a no and collecting errors nobody reads is work nobody asked for. A caller that wants to tell someone what went wrong asks for Basic.

    registry

    let registry : Registry

    No vocabularies beyond the built-in ones.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io