///|
/// One path-addressable payload validation problem.
pub(all) struct ValidationIssue {
code : String
path : String
message : String
} derive(Eq, Debug)
///|
/// Result of validating one JSON payload against one object type.
pub(all) struct ValidationResult {
object_name : String
issues : Array[ValidationIssue]
} derive(Eq, Debug)
///|
/// Failure that prevents validation from starting.
pub(all) struct InstanceError {
code : String
message : String
} derive(Eq, Debug)
///|
/// Independent validation results for a generated compatibility witness.
pub(all) struct WitnessVerification {
source_valid : Bool
target_valid : Bool
source_issues : Array[ValidationIssue]
target_issues : Array[ValidationIssue]
} derive(Eq, Debug)
///|
pub fn ValidationResult::is_valid(self : ValidationResult) -> Bool {
self.issues.is_empty()
}
///|
/// Parse and validate a JSON payload against a named object in the contract.
pub fn validate_json(
contract : Contract,
object_name : String,
input : String,
) -> Result[ValidationResult, InstanceError] {
if !@json.valid(input) {
return Err({ code: "INVALID_JSON", message: "payload is not valid JSON" })
}
let value = @json.parse(input) catch {
_ =>
return Err({ code: "INVALID_JSON", message: "payload is not valid JSON" })
}
validate_value(contract, object_name, value)
}
///|
/// Validate an already parsed JSON value.
pub fn validate_value(
contract : Contract,
object_name : String,
value : Json,
) -> Result[ValidationResult, InstanceError] {
guard contract.find_object(object_name) is Some(object) else {
return Err({
code: "UNKNOWN_ROOT_TYPE",
message: "contract does not define object '" + object_name + "'",
})
}
let issues : Array[ValidationIssue] = []
validate_object_value(contract, object, value, "$." + object_name, issues)
Ok({ object_name, issues })
}
///|
/// Re-run a generated witness through both contracts.
pub fn verify_witness(
source : Contract,
target : Contract,
object_name : String,
witness : Witness,
) -> Result[WitnessVerification, InstanceError] {
let source_result = match
validate_json(source, object_name, witness.payload) {
Ok(result) => result
Err(error) => return Err(error)
}
let target_result = match
validate_json(target, object_name, witness.payload) {
Ok(result) => result
Err(error) => return Err(error)
}
Ok({
source_valid: source_result.is_valid(),
target_valid: target_result.is_valid(),
source_issues: source_result.issues,
target_issues: target_result.issues,
})
}
///|
fn validate_object_value(
contract : Contract,
object : ObjectType,
value : Json,
path : String,
issues : Array[ValidationIssue],
) -> Unit {
guard value is Object(members) else {
issues.push({
code: "EXPECTED_OBJECT",
path,
message: "expected object '" + object.name + "'",
})
return
}
for field in object.fields {
match members.get(field.name) {
None =>
if field.required {
issues.push({
code: "REQUIRED_FIELD_MISSING",
path: path + "." + field.name,
message: "required field is missing",
})
}
Some(field_value) =>
validate_field_value(
contract,
field,
field_value,
path + "." + field.name,
issues,
)
}
}
if !object.open {
for name in members.keys() {
if !object.has_field(name) {
issues.push({
code: "UNKNOWN_FIELD",
path: path + "." + name,
message: "closed object does not declare this field",
})
}
}
}
}
///|
fn validate_field_value(
contract : Contract,
field : Field,
value : Json,
path : String,
issues : Array[ValidationIssue],
) -> Unit {
match field.type_expr {
StringType =>
match value {
String(text) => validate_length(field, text.char_length(), path, issues)
_ => type_issue("string", path, issues)
}
IntType =>
match value {
Number(number, ..) =>
if number.floor() != number {
type_issue("integer", path, issues)
} else {
validate_number(field, number, path, issues)
}
_ => type_issue("integer", path, issues)
}
NumberType =>
match value {
Number(number, ..) => validate_number(field, number, path, issues)
_ => type_issue("number", path, issues)
}
BoolType =>
match value {
True | False => ()
_ => type_issue("boolean", path, issues)
}
EnumType(values) =>
match value {
String(text) =>
if !values.contains(text) {
issues.push({
code: "ENUM_VALUE_NOT_ALLOWED",
path,
message: "value '" + text + "' is not in enum:" + values.join("|"),
})
}
_ => type_issue("enum string", path, issues)
}
RefType(name) =>
match contract.find_object(name) {
Some(object) =>
validate_object_value(contract, object, value, path, issues)
None =>
issues.push({
code: "UNKNOWN_REFERENCED_TYPE",
path,
message: "referenced type '" + name + "' is unavailable",
})
}
ListType(item) =>
match value {
Array(values) => {
validate_length(field, values.length(), path, issues)
for index, item_value in values {
validate_list_item(
contract,
item,
item_value,
path + "[" + index.to_string() + "]",
issues,
)
}
}
_ => type_issue("array", path, issues)
}
}
}
///|
fn validate_list_item(
contract : Contract,
item : String,
value : Json,
path : String,
issues : Array[ValidationIssue],
) -> Unit {
match item {
"string" => if !(value is String(_)) { type_issue("string", path, issues) }
"int" =>
match value {
Number(number, ..) if number.floor() == number => ()
_ => type_issue("integer", path, issues)
}
"number" =>
if !(value is Number(_, ..)) {
type_issue("number", path, issues)
}
"bool" =>
if !(value is True || value is False) {
type_issue("boolean", path, issues)
}
_ => {
let name = item[4:].to_owned()
match contract.find_object(name) {
Some(object) =>
validate_object_value(contract, object, value, path, issues)
None =>
issues.push({
code: "UNKNOWN_REFERENCED_TYPE",
path,
message: "referenced type '" + name + "' is unavailable",
})
}
}
}
}
///|
fn validate_number(
field : Field,
number : Double,
path : String,
issues : Array[ValidationIssue],
) -> Unit {
match field.constraints.min_int {
Some(minimum) =>
if number < minimum.to_double() {
issues.push({
code: "NUMBER_BELOW_MINIMUM",
path,
message: "number is below minimum " + minimum.to_string(),
})
}
None => ()
}
match field.constraints.max_int {
Some(maximum) =>
if number > maximum.to_double() {
issues.push({
code: "NUMBER_ABOVE_MAXIMUM",
path,
message: "number is above maximum " + maximum.to_string(),
})
}
None => ()
}
}
///|
fn validate_length(
field : Field,
length : Int,
path : String,
issues : Array[ValidationIssue],
) -> Unit {
match field.constraints.min_len {
Some(minimum) =>
if length < minimum {
issues.push({
code: "LENGTH_BELOW_MINIMUM",
path,
message: "length is below minimum " + minimum.to_string(),
})
}
None => ()
}
match field.constraints.max_len {
Some(maximum) =>
if length > maximum {
issues.push({
code: "LENGTH_ABOVE_MAXIMUM",
path,
message: "length is above maximum " + maximum.to_string(),
})
}
None => ()
}
}
///|
fn type_issue(
expected : String,
path : String,
issues : Array[ValidationIssue],
) -> Unit {
issues.push({ code: "TYPE_MISMATCH", path, message: "expected " + expected })
}