///|
/// Checking a JSON document against a JSON Schema.
///
/// The dialect read here is the part of the one at json-schema.org that a
/// document of data is described with: `type`, `required`, `properties`,
/// `items`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength` and
/// `pattern`. Every other keyword is left alone rather than refused, so a schema
/// written for a full validator can be handed to this one — the part of it this
/// tool does not read is simply not enforced, and a keyword that is not read
/// cannot be reported as a document that does not match.
///
/// Two questions are asked here, and they are asked separately because they have
/// two different answers. `schema_problems` reads the schema itself: whether
/// every keyword it uses holds a value this tool can do anything with. A schema
/// that fails it is a mistake in what the run was told, not a document that is
/// wrong, and the caller can say so before the document is looked at.
/// `validate_schema` then checks a document against a schema that has been read,
/// and answers with every place the two disagree rather than the first one: a
/// reader fixing a document wants the list, not a walk through the same command
/// ten times.
///
/// Both answer with `SchemaError`, whose `path` is the path of the value that
/// was being looked at. For `validate_schema` that is a path in the document —
/// the paths `--paths` prints, where the root is the empty path and a member is
/// joined to its object with a dot. For `schema_problems` it is a path in the
/// schema, to the keyword or to the value under it that cannot be read.
///|
/// One place where a schema and something else disagree.
///
/// `path` names where the trouble is and `message` says what it is, in a
/// sentence about the value at that path: a report is the two joined with a
/// colon, one error to a line. The message never names the path itself, so the
/// same error reads the same wherever it is found.
pub(all) struct SchemaError {
path : String
message : String
}
///|
/// Every place `json` and `schema` disagree.
///
/// The schema is expected to have been through `schema_problems`: a keyword
/// holding something this tool cannot read is passed over here rather than
/// reported, since what is wrong with it is a fact about the schema, which is
/// the other question.
pub fn validate_schema(
json : @pjson.Json,
schema : @pjson.Json,
) -> Array[SchemaError] {
let errors = []
check_value(json, schema, "", errors)
errors
}
///|
/// A schema error as one line: where it is and what it is.
///
/// The root has the empty path, which would leave nothing in front of the
/// colon, so it is named instead: a report is read line by line and a line that
/// starts with a colon says nothing about where it is about.
pub fn render_schema_error(error : SchemaError) -> String {
(if error.path == "" { "(root)" } else { error.path }) + ": " + error.message
}
///|
/// Every keyword in `schema` that this tool cannot read.
///
/// Read before a document is: what is being checked is that the schema is one
/// this tool knows how to apply, and a schema that is not is answered with the
/// place to fix rather than with a document that was never compared to it.
pub fn schema_problems(schema : @pjson.Json) -> Array[SchemaError] {
let errors = []
check_schema(schema, "", errors)
errors
}
///|
/// Check one value against one schema, adding every disagreement to `errors`.
///
/// A keyword says what it is about as much as what it wants: `required` is about
/// an object, `minLength` about a string, `items` about an array. A value of
/// another kind is left alone by it — there is no member that could be missing
/// from a number — and it is `type` that says a number is the wrong kind of value
/// to find here.
fn check_value(
value : @pjson.Json,
schema : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
match schema {
Object(members~) =>
for entry in members {
let (keyword, wanted) = entry
match keyword {
"type" => check_type(value, wanted, path, errors)
"required" => check_required(value, wanted, path, errors)
"properties" => check_properties(value, wanted, path, errors)
"items" => check_items(value, wanted, path, errors)
"enum" => check_enum(value, wanted, path, errors)
"minimum" => check_bound(value, wanted, path, errors, true)
"maximum" => check_bound(value, wanted, path, errors, false)
"minLength" => check_length(value, wanted, path, errors, true)
"maxLength" => check_length(value, wanted, path, errors, false)
"pattern" => check_pattern(value, wanted, path, errors)
_ => ()
}
}
// Unreachable: `schema_problems` refuses a schema that is not an object.
_ => ()
}
}
///|
/// Whether a value is of the type a name stands for.
fn type_matches(json : @pjson.Json, name : String) -> Bool {
match name {
"object" =>
match json {
Object(_) => true
_ => false
}
"array" =>
match json {
Array(_) => true
_ => false
}
"string" =>
match json {
Text(_) => true
_ => false
}
"number" =>
match json {
Number(_) => true
_ => false
}
// An integer is a number with nothing after the point rather than a number
// that is not written as a fraction: `1.0` and `1e3` are both integers, and
// reading them as doubles to find that out would answer for a numeral too
// long for a double by rounding it first.
"integer" =>
match json {
Number(raw~) => is_whole_number(raw)
_ => false
}
"boolean" =>
match json {
Bool(_) => true
_ => false
}
"null" =>
match json {
Null => true
_ => false
}
// Unreachable: `schema_problems` refuses any other name.
_ => false
}
}
///|
/// Whether a numeral stands for a whole number: `1`, `1.0` and `1e3` do, `1.5`
/// and `0.25` do not.
///
/// Read off the text the document was written with, so the answer does not
/// depend on what a double can hold: a numeral with thirty digits and nothing
/// after the point is a whole number whatever it is too large to become.
fn is_whole_number(raw : String) -> Bool {
let chars = raw.to_array()
let mut index = if chars.length() > 0 && (chars[0] == '-' || chars[0] == '+') {
1
} else {
0
}
let mut whole = true
while index < chars.length() {
let ch = chars[index]
if ch == '.' {
whole = false
} else if ch == 'e' || ch == 'E' {
// An exponent moves the point without putting anything after it, so every
// numeral with one stands for a whole number or for nothing at all.
return true
} else if !ch.is_ascii_digit() {
return false
} else if !whole && ch != '0' {
return false
}
index = index + 1
}
true
}
///|
/// How a JSON type name reads in a sentence, article and all.
fn type_name(name : String) -> String {
match name {
"object" => "an object"
"array" => "an array"
"string" => "a string"
"number" => "a number"
"integer" => "an integer"
"boolean" => "a boolean"
"null" => "null"
// Unreachable: `schema_problems` refuses any other name.
other => other
}
}
///|
/// The names a `type` keyword holds, whether it holds one or a list of them.
fn type_names(wanted : @pjson.Json) -> Array[String] {
match wanted {
Text(value~) => [value]
Array(items~) =>
items.filter_map(fn(item) {
match item {
Text(value~) => Some(value)
// Unreachable: `schema_problems` refuses the rest.
_ => None
}
})
// A `type` that is neither is one this tool cannot read, and there is
// nothing to expect of the value: `schema_problems` is where it is answered.
_ => []
}
}
///|
/// Join a list of names into a phrase: `a`, `a or b`, `a, b or c`.
fn join_or(names : Array[String]) -> String {
match names.length() {
0 => ""
1 => names[0]
_ => {
let head = names[0:names.length() - 1].join(", ")
head + " or " + names[names.length() - 1]
}
}
}
///|
/// `type`: the value has to be of one of the kinds the schema names.
fn check_type(
value : @pjson.Json,
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
let names = type_names(wanted)
if names.length() == 0 {
return
}
let mut matched = false
for name in names {
if type_matches(value, name) {
matched = true
}
}
if !matched {
errors.push({
path,
message: "it is " +
kind_name(value) +
" where the schema expects " +
join_or(names.map(type_name)),
})
}
}
///|
/// `required`: every name has to be a member of the object.
///
/// The names are checked in the order the schema lists them, so a report reads
/// in that order rather than in the order the object happens to be missing them.
fn check_required(
value : @pjson.Json,
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
let names = match wanted {
Array(items~) =>
items.filter_map(fn(item) {
match item {
Text(value~) => Some(value)
// Unreachable: `schema_problems` refuses the rest.
_ => None
}
})
// Unreachable: `schema_problems` refuses a `required` that is not a list.
_ => []
}
match value {
Object(members~) =>
for name in names {
let mut found = false
for entry in members {
if entry.0 == name {
found = true
}
}
if !found {
errors.push({
path,
message: "required member \"" + name + "\" is missing",
})
}
}
// A `required` says nothing about a value that has no members, and `type`
// is the keyword that says the value should not be one.
_ => ()
}
}
///|
/// `properties`: every member the schema names is checked against the schema it
/// is given, and a member it does not name is left as it is.
///
/// Members the document does not have are not reported here: a schema that
/// requires them says so with `required`, and one that does not means what it
/// says. The walk follows the schema rather than the document, so a report reads
/// in the order the schema lists what it is about, which is the order its reader
/// reads.
fn check_properties(
value : @pjson.Json,
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
let subschemas = match wanted {
Object(members~) => members
// Unreachable: `schema_problems` refuses a `properties` that is not an
// object.
_ => []
}
let members = match value {
Object(members~) => members
// A `properties` says nothing about a value that has no members, and `type`
// is the keyword that says the value should not be one.
_ => []
}
for entry in subschemas {
match find_member(members, entry.0) {
Some(held) =>
check_value(held, entry.1, child_path(path, entry.0), errors)
None => ()
}
}
}
///|
/// `items`: every element of the array is checked against the schema.
fn check_items(
value : @pjson.Json,
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
match value {
Array(items~) =>
for index, item in items {
check_value(item, wanted, path + "[" + index.to_string() + "]", errors)
}
_ => ()
}
}
///|
/// `enum`: the value has to be one of the values listed.
///
/// The message gives the number of them rather than the values: the list is in
/// the schema the reader has to hand, and a schema naming fifty values would
/// otherwise put all fifty on one line of the report.
fn check_enum(
value : @pjson.Json,
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
let allowed = match wanted {
Array(items~) => items
// A keyword this tool cannot read is a fact about the schema, which
// `schema_problems` answers, and not a document that does not match it: a
// list of no values would have said every document is wrong.
_ => return
}
let mut found = false
for allowed_value in allowed {
if same_value(value, allowed_value) {
found = true
}
}
if !found {
errors.push({ path, message: "it is not " + listed(allowed.length()), })
}
}
///|
/// How a number of listed values reads in a sentence: `the value the schema
/// lists`, `one of the 3 values the schema lists`.
///
/// A list with nothing in it lists no values at all, and a value it holds
/// nothing for is a value the schema cannot accept rather than one it does not
/// know.
fn listed(count : Int) -> String {
match count {
0 => "one of the values the schema lists"
1 => "the value the schema lists"
_ => "one of the " + count.to_string() + " values the schema lists"
}
}
///|
/// `minimum` and `maximum`: the number has to be inside the bound, which is
/// included in it.
///
/// The bound is compared as the number it denotes, so `5` and `5.0` are one
/// bound, and a numeral past the end of the double range is read as the end it
/// went past — the reading `--sort-by` compares numbers with.
fn check_bound(
value : @pjson.Json,
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
least : Bool,
) -> Unit {
let bound = match wanted {
Number(raw~) => Some(number_value(raw))
// Unreachable: `schema_problems` refuses a bound that is not a number.
_ => None
}
match (value, bound) {
(Number(raw~), Some(bound)) => {
let number = number_value(raw)
if least && number < bound {
errors.push({
path,
message: "it is less than the minimum " + raw_of(wanted),
})
}
if !least && number > bound {
errors.push({
path,
message: "it is more than the maximum " + raw_of(wanted),
})
}
}
_ => ()
}
}
///|
/// `minLength` and `maxLength`: the string has to be long enough, or short
/// enough. The length is counted in characters, as the columns of a diagnostic
/// are.
fn check_length(
value : @pjson.Json,
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
least : Bool,
) -> Unit {
let length = match length_argument(wanted) {
Some(length) => length
// Unreachable: `schema_problems` refuses a length that is not a count.
None => return
}
match value {
Text(value~) => {
let count = count_chars(value)
if least && count < length {
errors.push({
path,
message: "it is " +
characters(count) +
" long, and the schema asks for at least " +
length.to_string(),
})
}
if !least && count > length {
errors.push({
path,
message: "it is " +
characters(count) +
" long, and the schema asks for at most " +
length.to_string(),
})
}
}
_ => ()
}
}
///|
/// How a length reads in a sentence, singular and all: `1 character`, `2
/// characters`.
fn characters(count : Int) -> String {
if count == 1 {
"1 character"
} else {
count.to_string() + " characters"
}
}
///|
/// `pattern`: the string has to hold a match of the expression.
///
/// The expression is matched by `pattern.mbt`, whose subset of regular
/// expressions is the one schemas are written with; a pattern it cannot read has
/// already been reported by `schema_problems`, and a document is not the place
/// to say so again.
fn check_pattern(
value : @pjson.Json,
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
let pattern = match text_of(wanted) {
Some(pattern) => pattern
// Unreachable: `schema_problems` refuses a `pattern` that is not a string.
None => return
}
match text_of(value) {
Some(text) =>
match pattern_matches(pattern, text) {
Ok(true) => ()
Ok(false) =>
errors.push({
path,
message: "it does not match the pattern \"" + pattern + "\"",
})
Err(_) => ()
}
_ => ()
}
}
///|
/// Every keyword in the schema that this tool cannot read, with the path of the
/// keyword it is under.
///
/// A schema is read the same way at every level, so this walks into the schemas
/// inside it: `properties` for the members it names and `items` for the elements
/// of an array. A keyword that is not one of the ten this tool reads is passed
/// over without being looked at, whatever it holds.
fn check_schema(
schema : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
match schema {
Object(members~) =>
for entry in members {
let (keyword, wanted) = entry
let here = child_path(path, keyword)
match keyword {
"type" => check_type_argument(wanted, here, errors)
"required" => check_name_list(wanted, here, errors)
"properties" =>
match wanted {
Object(members=subschemas) =>
for entry in subschemas {
check_schema(entry.1, child_path(here, entry.0), errors)
}
_ =>
errors.push({
path: here,
message: "expected an object naming the members it is about, but this is " +
kind_name(wanted),
})
}
"items" =>
match wanted {
Object(_) => check_schema(wanted, here, errors)
_ =>
errors.push({
path: here,
message: "expected one schema for the items, but this is " +
kind_name(wanted) +
"; a list of schemas is not read here",
})
}
"enum" =>
match wanted {
Array(_) => ()
_ =>
errors.push({
path: here,
message: "expected a list of values, but this is " +
kind_name(wanted),
})
}
"minimum" | "maximum" =>
match wanted {
Number(_) => ()
_ =>
errors.push({
path: here,
message: "expected a number, but this is " + kind_name(wanted),
})
}
"minLength" | "maxLength" =>
match length_argument(wanted) {
Some(_) => ()
None =>
errors.push({
path: here,
message: match wanted {
// A number that is not a count is a number with a point in
// it, a negative one, or one no document could hold: saying
// which number it is leaves the reading of the message to
// the schema's author.
Number(raw~) =>
raw +
" is not a count of characters; a count is a whole number from 0 to 1000000000"
_ =>
"expected a count of characters, but this is " +
kind_name(wanted)
},
})
}
"pattern" => check_pattern_argument(wanted, here, errors)
// Every other keyword is a part of the dialect this tool does not
// read, and a schema is not refused for holding one: it says nothing
// about the document here, and nothing about it is wrong.
_ => ()
}
}
_ =>
errors.push({
path,
message: "a schema is an object of keywords, but this is " +
kind_name(schema),
})
}
}
///|
/// Whether a `type` keyword holds the name of a type or a list of them.
fn check_type_argument(
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
match wanted {
Text(value~) => check_type_name(value, path, errors)
Array(items~) =>
for index, item in items {
match item {
Text(value~) =>
check_type_name(value, path + "[" + index.to_string() + "]", errors)
_ =>
errors.push({
path: path + "[" + index.to_string() + "]",
message: "expected a JSON type name, but this is " +
kind_name(item),
})
}
}
_ =>
errors.push({
path,
message: "expected a JSON type name or a list of them, but this is " +
kind_name(wanted),
})
}
}
///|
/// Whether a name is one of the seven JSON types.
fn check_type_name(
name : String,
path : String,
errors : Array[SchemaError],
) -> Unit {
match name {
"object" | "array" | "string" | "number" | "integer" | "boolean" | "null" =>
()
_ =>
errors.push({
path,
message: "\"" +
name +
"\" is not a JSON type; the types are object, array, string, number, integer, boolean and null",
})
}
}
///|
/// Whether a `required` keyword holds a list of member names.
fn check_name_list(
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
match wanted {
Array(items~) =>
for index, item in items {
match item {
Text(_) => ()
_ =>
errors.push({
path: path + "[" + index.to_string() + "]",
message: "expected a member name, but this is " + kind_name(item),
})
}
}
_ =>
errors.push({
path,
message: "expected a list of member names, but this is " +
kind_name(wanted),
})
}
}
///|
/// The value of a `minLength` or `maxLength` keyword, if it is a count of
/// characters.
///
/// A length is a whole number of characters, so `3`, `3.0` and `1e3` are the
/// counts three, three and a thousand: what is not a count is a number with a
/// fraction in it, which would be a fraction of a character, and a negative one,
/// which no string is shorter than. A count past a billion is not read either —
/// no document this tool is handed holds that many characters of one string, and
/// reading it would mean answering with a number that is not there.
fn length_argument(wanted : @pjson.Json) -> Int? {
match wanted {
Number(raw~) =>
if !is_whole_number(raw) {
None
} else {
let count = number_value(raw)
if count < 0.0 || count > 1_000_000_000.0 {
None
} else {
Some(count.to_int())
}
}
_ => None
}
}
///|
/// Whether a `pattern` keyword holds an expression this tool can read,
/// answering with the reason it cannot if it does not.
fn check_pattern_argument(
wanted : @pjson.Json,
path : String,
errors : Array[SchemaError],
) -> Unit {
match text_of(wanted) {
Some(pattern) =>
match pattern_matches(pattern, "") {
Ok(_) => ()
Err(reason) =>
errors.push({
path,
message: "\"" +
pattern +
"\" is not a pattern this tool reads: " +
reason,
})
}
_ =>
errors.push({
path,
message: "expected a pattern, but this is " + kind_name(wanted),
})
}
}
///|
/// Whether two values are the same value, as `enum` means it.
///
/// Numbers are compared as the numbers they denote rather than as the text they
/// were written with, so `1` is the same value as `1.0` — the reading the sort
/// order compares them with. Two objects are the same when they hold the same
/// members, whatever order they were written in, and two arrays when they hold
/// the same values in the same order. Values of different kinds are never the
/// same one.
fn same_value(one : @pjson.Json, other : @pjson.Json) -> Bool {
match (one, other) {
(Null, Null) => true
(Bool(value=first), Bool(value=second)) => first == second
(Number(raw=first), Number(raw=second)) =>
number_value(first) == number_value(second)
(Text(value=first), Text(value=second)) => first == second
(Array(items=first), Array(items=second)) => {
let mut equal = first.length() == second.length()
if equal {
for index, item in first {
if !same_value(item, second[index]) {
equal = false
}
}
}
equal
}
(Object(members=first), Object(members=second)) => {
let mut equal = first.length() == second.length()
if equal {
for entry in first {
match find_member(second, entry.0) {
Some(value) => if !same_value(entry.1, value) { equal = false }
None => equal = false
}
}
}
equal
}
_ => false
}
}
///|
/// The text of a value, if it is a string.
fn text_of(json : @pjson.Json) -> String? {
match json {
Text(value~) => Some(value)
_ => None
}
}
///|
/// The value of one member, if the object has it.
fn find_member(
members : Array[(String, @pjson.Json)],
key : String,
) -> @pjson.Json? {
for entry in members {
if entry.0 == key {
return Some(entry.1)
}
}
None
}
///|
/// The path of a member of the value at `path`.
///
/// The root of a document has the empty path, which is the path `--paths` gives
/// it: a member of the root is named by its key alone, and everything below it
/// is joined with dots.
fn child_path(path : String, key : String) -> String {
if path == "" {
key
} else {
path + "." + key
}
}
///|
/// The text a number was written with.
fn raw_of(wanted : @pjson.Json) -> String {
match wanted {
Number(raw~) => raw
// Unreachable: every caller has already read the number out of it.
_ => ""
}
}