// The MoonCheck validation engine.
//
// `validate` walks a JSON value against a schema, collecting every problem it
// finds (rather than stopping at the first). Errors are reported in the order
// the schema declares things, so results are deterministic.
///|
/// Validate a JSON value against a schema.
///
/// Returns all validation errors. An empty array means the value is valid.
/// Undeclared fields of an object are ignored.
pub fn validate(schema : Type, value : Json) -> Array[ValidationError] {
let out : Array[ValidationError] = []
check(schema, value, "$", out)
return out
}
///|
/// Convenience: `true` when `validate` finds no error.
pub fn is_valid(schema : Type, value : Json) -> Bool {
validate(schema, value).is_empty()
}
///|
/// Validate in one step from raw text.
///
/// Takes a schema document and a data document, both as JSON text.
/// Returns `Ok(errors)` where an empty list means the data is valid, or
/// `Err(reason)` when either document could not be parsed.
pub fn validate_strings(
schema_text : String,
data_text : String,
) -> Result[Array[ValidationError], String] {
let schema = parse_schema_string(schema_text) catch {
SchemaError::InvalidSchema(msg) => return Err("invalid schema: \{msg}")
}
let data = @json.parse(data_text) catch {
err => return Err("data is not valid JSON: \{err}")
}
return Ok(validate(schema, data))
}
///|
/// Parse a schema document, returning the reason as a string on failure.
///
/// Batch and CLI callers parse the schema once and reuse it for every data
/// document, so a broken schema is reported separately from data errors.
pub fn parse_schema_text(text : String) -> Result[Type, String] {
Ok(parse_schema_string(text)) catch {
SchemaError::InvalidSchema(msg) => Err(msg)
}
}
///|
/// Validate a data document against an already parsed schema.
///
/// Unlike `validate_strings`, a data document that is not valid JSON is
/// reported as a single `InvalidJson` error instead of aborting the call, so
/// callers that process many documents can keep going and report everything.
pub fn validate_text(
schema : Type,
data_text : String,
) -> Array[ValidationError] {
let data = @json.parse(data_text) catch {
err =>
return [
{
path: "$",
kind: InvalidJson,
message: "data is not valid JSON: \{err}",
},
]
}
return validate(schema, data)
}
///|
fn check(
typ : Type,
value : Json,
path : String,
out : Array[ValidationError],
) -> Unit {
match typ {
Str(spec) => check_string(spec, value, path, out)
Num(spec) => check_numeric(spec, value, path, out, false)
Int(spec) => check_numeric(spec, value, path, out, true)
Bool => check_bool(value, path, out)
Obj(spec) => check_object(spec, value, path, out)
Arr(spec) => check_array(spec, value, path, out)
}
}
///|
fn check_string(
spec : StrSpec,
value : Json,
path : String,
out : Array[ValidationError],
) -> Unit {
match value {
String(s) => {
let len = s.char_length()
if spec.min_length is Some(m) && len < m {
out.push({
path,
kind: TooShort,
message: "length must be at least \{m}, got \{len}",
})
}
if spec.max_length is Some(m) && len > m {
out.push({
path,
kind: TooLong,
message: "length must be at most \{m}, got \{len}",
})
}
}
_ => push_type_error(path, "String", value, out)
}
}
///|
fn check_numeric(
spec : NumSpec,
value : Json,
path : String,
out : Array[ValidationError],
integer_only : Bool,
) -> Unit {
match value {
Number(n, ..) => {
if !is_finite(n) {
out.push({
path,
kind: InvalidNumber,
message: "number must be finite",
})
return
}
if integer_only && !is_integer(n) {
push_type_error(path, "Int", value, out)
return
}
if spec.min is Some(m) && n < m {
out.push({
path,
kind: BelowMinimum,
message: "value must be at least \{fmt_num(m)}, got \{fmt_num(n)}",
})
}
if spec.max is Some(m) && n > m {
out.push({
path,
kind: AboveMaximum,
message: "value must be at most \{fmt_num(m)}, got \{fmt_num(n)}",
})
}
}
_ => {
let expected = if integer_only { "Int" } else { "Number" }
push_type_error(path, expected, value, out)
}
}
}
///|
fn check_bool(
value : Json,
path : String,
out : Array[ValidationError],
) -> Unit {
match value {
True | False => ()
_ => push_type_error(path, "Bool", value, out)
}
}
///|
fn check_object(
spec : ObjSpec,
value : Json,
path : String,
out : Array[ValidationError],
) -> Unit {
match value {
Object(members) =>
for (name, prop) in spec.properties {
let field_path = child_path(path, name)
match members.get(name) {
None =>
if prop.required {
out.push({
path: field_path,
kind: MissingRequired,
message: "required field is missing",
})
}
Some(field_value) => {
check(prop.typ, field_value, field_path, out)
if prop.enums is Some(allowed) && !in_list(allowed, field_value) {
let list = Json::array(allowed).stringify()
out.push({
path: field_path,
kind: NotInEnum,
message: "value must be one of \{list}",
})
}
}
}
}
_ => push_type_error(path, "Object", value, out)
}
}
///|
fn check_array(
spec : ArrSpec,
value : Json,
path : String,
out : Array[ValidationError],
) -> Unit {
match value {
Array(items) => {
let len = items.length()
if spec.min_length is Some(m) && len < m {
out.push({
path,
kind: TooShort,
message: "length must be at least \{m}, got \{len}",
})
}
if spec.max_length is Some(m) && len > m {
out.push({
path,
kind: TooLong,
message: "length must be at most \{m}, got \{len}",
})
}
for i in 0.. push_type_error(path, "Array", value, out)
}
}
///|
fn push_type_error(
path : String,
expected : String,
value : Json,
out : Array[ValidationError],
) -> Unit {
let got = value_name(value)
out.push({ path, kind: Type, message: "expected \{expected}, got \{got}", })
}
///|
fn child_path(path : String, name : String) -> String {
if path == "$" {
"$.\{name}"
} else {
"\{path}.\{name}"
}
}
///|
/// Element-wise membership test (requires `Json` equality).
fn in_list(values : Array[Json], value : Json) -> Bool {
for v in values {
if v == value {
return true
}
}
return false
}
///|
fn is_finite(n : Double) -> Bool {
!n.is_nan() && n != @double.infinity && n != @double.neg_infinity
}
///|
/// `true` when `n` is an integer-valued finite double (no fractional part).
fn is_integer(n : Double) -> Bool {
is_finite(n) && n == @double.floor(n)
}
///|
/// Format a double for error messages without a trailing `.0` on whole numbers
/// that fit in an `Int64`.
fn fmt_num(n : Double) -> String {
if n.is_nan() {
return "NaN"
}
if n == @double.infinity {
return "infinity"
}
if n == @double.neg_infinity {
return "-infinity"
}
let whole = n.to_int64()
if n == whole.to_double() {
return whole.to_string()
}
return n.to_string()
}