///|
/// Validation rules for common CBOR application payloads.
pub(all) enum ValidationRule {
Required(String)
RequiredText(String)
RequiredBool(String)
RequiredInteger(String)
IntegerRange(String, Int64, Int64)
TextLength(String, Int, Int)
ArrayLength(String, Int, Int)
ObjectFields(String, Array[ValidationRule])
ArrayItems(String, Array[ValidationRule])
OneOfText(String, Array[String])
} derive(Eq, Debug)
///|
/// One machine-readable issue found during validation.
pub(all) struct ValidationIssue {
path : String
code : String
message : String
} derive(Eq, Debug)
///|
/// A complete validation result; all independent issues are retained.
pub(all) struct ValidationReport {
valid : Bool
issues : Array[ValidationIssue]
} derive(Eq, Debug)
///|
/// Construct a required-field rule.
pub fn validation_required(name : String) -> ValidationRule {
Required(name)
}
///|
/// Construct a required non-empty text rule.
pub fn validation_required_text(name : String) -> ValidationRule {
RequiredText(name)
}
///|
/// Construct a required boolean rule.
pub fn validation_required_bool(name : String) -> ValidationRule {
RequiredBool(name)
}
///|
/// Construct a required integer rule.
pub fn validation_required_integer(name : String) -> ValidationRule {
RequiredInteger(name)
}
///|
/// Construct an inclusive integer range rule.
pub fn validation_int_range(
name : String,
minimum : Int64,
maximum : Int64,
) -> ValidationRule {
IntegerRange(name, minimum, maximum)
}
///|
/// Construct an inclusive UTF-16 string length rule.
pub fn validation_text_length(
name : String,
minimum : Int,
maximum : Int,
) -> ValidationRule {
TextLength(name, minimum, maximum)
}
///|
/// Construct an inclusive array length rule.
pub fn validation_array_length(
name : String,
minimum : Int,
maximum : Int,
) -> ValidationRule {
ArrayLength(name, minimum, maximum)
}
///|
/// Construct a nested object rule.
pub fn validation_object(
name : String,
rules : Array[ValidationRule],
) -> ValidationRule {
ObjectFields(name, rules)
}
///|
/// Construct a rule applied to every item in an array.
pub fn validation_items(
name : String,
rules : Array[ValidationRule],
) -> ValidationRule {
ArrayItems(name, rules)
}
///|
/// Construct a finite text enumeration rule.
pub fn validation_one_of(
name : String,
allowed : Array[String],
) -> ValidationRule {
OneOfText(name, allowed)
}
///|
fn validation_issue(
path : String,
code : String,
message : String,
) -> ValidationIssue {
{ path, code, message }
}
///|
fn validation_report(issues : Array[ValidationIssue]) -> ValidationReport {
{ valid: issues.length() == 0, issues }
}
///|
/// Return a stable single-line representation for log aggregation.
pub fn validation_issue_to_string(issue : ValidationIssue) -> String {
issue.code + " at " + issue.path + ": " + issue.message
}
///|
/// Render all issues in deterministic order.
pub fn validation_report_to_string(report : ValidationReport) -> String {
if report.valid {
"valid"
} else {
let builder = StringBuilder::new()
for i = 0; i < report.issues.length(); i = i + 1 {
if i > 0 {
builder.write_string("\n")
}
builder.write_string(validation_issue_to_string(report.issues[i]))
}
builder.to_string()
}
}
///|
/// Return the number of errors in a validation report.
pub fn validation_error_count(report : ValidationReport) -> Int {
report.issues.length()
}
///|
fn value_field(value : CborValue, name : String) -> CborValue? {
match value {
Map(entries) => {
for entry in entries {
match entry.0 {
Text(key) => if key == name { return Some(entry.1) }
_ => ()
}
}
None
}
_ => None
}
}
///|
fn value_at_rule(
value : CborValue,
name : String,
parent : String,
) -> (CborValue, String)? {
match value_field(value, name) {
Some(found) => Some((found, parent + "." + name))
None => None
}
}
///|
fn validate_text_value(
value : CborValue,
path : String,
issues : Array[ValidationIssue],
required : Bool,
) -> Unit {
match value {
Text(text) =>
if required && text.length() == 0 {
issues.push(
validation_issue(path, "empty_text", "text must not be empty"),
)
}
_ => issues.push(validation_issue(path, "type", "expected text"))
}
}
///|
fn validate_bool_value(
value : CborValue,
path : String,
issues : Array[ValidationIssue],
) -> Unit {
match value {
Simple(20) | Simple(21) => ()
_ => issues.push(validation_issue(path, "type", "expected boolean"))
}
}
///|
fn integer_value(value : CborValue) -> Int64? {
match value {
Integer(number) => Some(number)
Unsigned(number) if number <= 0x7FFFFFFFFFFFFFFFUL =>
Some(number.reinterpret_as_int64())
_ => None
}
}
///|
fn validate_integer_value(
value : CborValue,
path : String,
issues : Array[ValidationIssue],
) -> Int64? {
match integer_value(value) {
Some(number) => Some(number)
None => {
issues.push(
validation_issue(path, "type", "expected Int64-compatible integer"),
)
None
}
}
}
///|
fn validate_rule(
value : CborValue,
rule : ValidationRule,
path : String,
issues : Array[ValidationIssue],
) -> Unit {
match rule {
Required(name) =>
match value_at_rule(value, name, path) {
Some(_) => ()
None =>
issues.push(
validation_issue(path + "." + name, "required", "field is required"),
)
}
RequiredText(name) =>
match value_at_rule(value, name, path) {
Some((found, field_path)) =>
validate_text_value(found, field_path, issues, true)
None =>
issues.push(
validation_issue(
path + "." + name,
"required",
"text field is required",
),
)
}
RequiredBool(name) =>
match value_at_rule(value, name, path) {
Some((found, field_path)) =>
validate_bool_value(found, field_path, issues)
None =>
issues.push(
validation_issue(
path + "." + name,
"required",
"boolean field is required",
),
)
}
RequiredInteger(name) =>
match value_at_rule(value, name, path) {
Some((found, field_path)) =>
ignore(validate_integer_value(found, field_path, issues))
None =>
issues.push(
validation_issue(
path + "." + name,
"required",
"integer field is required",
),
)
}
IntegerRange(name, minimum, maximum) =>
match value_at_rule(value, name, path) {
Some((found, field_path)) =>
match validate_integer_value(found, field_path, issues) {
Some(number) =>
if number < minimum || number > maximum {
issues.push(
validation_issue(
field_path, "range", "integer is outside the inclusive range",
),
)
}
None => ()
}
None =>
issues.push(
validation_issue(
path + "." + name,
"required",
"integer field is required",
),
)
}
TextLength(name, minimum, maximum) =>
match value_at_rule(value, name, path) {
Some((found, field_path)) =>
match found {
Text(text) =>
if text.length() < minimum || text.length() > maximum {
issues.push(
validation_issue(
field_path, "length", "text length is outside the inclusive range",
),
)
}
_ =>
issues.push(validation_issue(field_path, "type", "expected text"))
}
None =>
issues.push(
validation_issue(
path + "." + name,
"required",
"text field is required",
),
)
}
ArrayLength(name, minimum, maximum) =>
match value_at_rule(value, name, path) {
Some((found, field_path)) =>
match found {
Array(items) =>
if items.length() < minimum || items.length() > maximum {
issues.push(
validation_issue(
field_path, "length", "array length is outside the inclusive range",
),
)
}
_ =>
issues.push(
validation_issue(field_path, "type", "expected array"),
)
}
None =>
issues.push(
validation_issue(
path + "." + name,
"required",
"array field is required",
),
)
}
ObjectFields(name, nested_rules) =>
match value_at_rule(value, name, path) {
Some((found, field_path)) =>
match found {
Map(_) =>
for nested_rule in nested_rules {
validate_rule(found, nested_rule, field_path, issues)
}
_ =>
issues.push(
validation_issue(field_path, "type", "expected object"),
)
}
None =>
issues.push(
validation_issue(
path + "." + name,
"required",
"object field is required",
),
)
}
ArrayItems(name, item_rules) =>
match value_at_rule(value, name, path) {
Some((found, field_path)) =>
match found {
Array(items) =>
for i = 0; i < items.length(); i = i + 1 {
let item_path = field_path + "[" + i.to_string() + "]"
for item_rule in item_rules {
validate_rule(items[i], item_rule, item_path, issues)
}
}
_ =>
issues.push(
validation_issue(field_path, "type", "expected array"),
)
}
None =>
issues.push(
validation_issue(
path + "." + name,
"required",
"array field is required",
),
)
}
OneOfText(name, allowed) =>
match value_at_rule(value, name, path) {
Some((found, field_path)) =>
match found {
Text(text) => {
let mut found_allowed = false
for item in allowed {
if item == text {
found_allowed = true
}
}
if !found_allowed {
issues.push(
validation_issue(
field_path, "enum", "text is not an allowed value",
),
)
}
}
_ =>
issues.push(validation_issue(field_path, "type", "expected text"))
}
None =>
issues.push(
validation_issue(
path + "." + name,
"required",
"text field is required",
),
)
}
}
}
///|
/// Validate an object and retain every independent field issue.
pub fn validate_object_payload(
value : CborValue,
rules : Array[ValidationRule],
) -> ValidationReport {
let issues = []
match value {
Map(_) =>
for rule in rules {
validate_rule(value, rule, "$", issues)
}
_ => issues.push(validation_issue("$", "type", "payload must be an object"))
}
validation_report(issues)
}
///|
/// Validate a message payload and prefix its errors with the message ID.
pub fn validate_message_payload(
message : AppMessage,
rules : Array[ValidationRule],
) -> ValidationReport {
let report = validate_object_payload(message.payload, rules)
let issues = []
for issue in report.issues {
issues.push(
validation_issue(
"message[" + message.id + "]" + issue.path,
issue.code,
issue.message,
),
)
}
validation_report(issues)
}
///|
/// Reject unknown text fields when an application has a closed schema.
pub fn validation_unknown_fields(
value : CborValue,
allowed : Array[String],
) -> ValidationReport {
let issues = []
match value {
Map(entries) =>
for entry in entries {
match entry.0 {
Text(name) => {
let mut known = false
for item in allowed {
if item == name {
known = true
}
}
if !known {
issues.push(
validation_issue("$." + name, "unknown", "field is not allowed"),
)
}
}
_ =>
issues.push(
validation_issue("$", "key_type", "object key must be text"),
)
}
}
_ => issues.push(validation_issue("$", "type", "payload must be an object"))
}
validation_report(issues)
}
///|
/// Compose two reports without losing issue order.
pub fn validation_combine(
first : ValidationReport,
second : ValidationReport,
) -> ValidationReport {
let issues = []
for issue in first.issues {
issues.push(issue)
}
for issue in second.issues {
issues.push(issue)
}
validation_report(issues)
}
///|
/// Add a path prefix to every issue in a report.
pub fn validation_prefix(
prefix : String,
report : ValidationReport,
) -> ValidationReport {
let issues = []
for issue in report.issues {
issues.push(
validation_issue(prefix + issue.path, issue.code, issue.message),
)
}
validation_report(issues)
}
///|
/// Return a report containing one explicit business-rule error.
pub fn validation_business_error(
path : String,
code : String,
message : String,
) -> ValidationReport {
validation_report([validation_issue(path, code, message)])
}
///|
/// Return true when a value passes a set of object rules.
pub fn validation_succeeds(
value : CborValue,
rules : Array[ValidationRule],
) -> Bool {
validate_object_payload(value, rules).valid
}