///|
/// Validate a JSON value against the supported OpenRPC Schema subset.
///
/// The validator is deliberately pure: it does not resolve references, load
/// files, coerce values, or perform network I/O. A schema may be a boolean
/// schema or an object containing the supported JSON Schema keywords.
pub fn validate_schema(
value : Json,
schema : Json,
) -> Result[Unit, Array[Diagnostic]] {
validate_schema_at(value, schema, "$")
}
///|
fn validate_schema_at(
value : Json,
schema : Json,
path : String,
) -> Result[Unit, Array[Diagnostic]] {
match schema {
True => Ok(())
False => schema_violation(path, "boolean schema rejects the value")
Object(schema_object) => validate_schema_object(value, schema_object, path)
_ => schema_definition_error(path, "schema must be a boolean or object")
}
}
///|
fn validate_schema_object(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
if schema.get("$ref") is Some(_) {
return schema_definition_error(
path + ".$ref",
"schema references must be validated through a parsed Document",
)
}
match validate_type_keyword(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
match validate_enum_keyword(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
match validate_const_keyword(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
match validate_all_of(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
match validate_any_of(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
match validate_one_of(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
match validate_not(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
match validate_string_keywords(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
match validate_number_keywords(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
match validate_array_keywords(value, schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(_) => ()
}
validate_object_keywords(value, schema, path)
}
///|
fn validate_type_keyword(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
match schema.get("type") {
None => Ok(())
Some(String(type_name)) => {
if !known_schema_type(type_name) {
return schema_definition_error(
path + ".type",
"schema type is not supported",
)
}
if !matches_schema_type(value, type_name) {
return schema_violation(
path,
"value does not match the schema type " + type_name,
)
}
Ok(())
}
Some(Array(type_values)) => {
if type_values.length() == 0 {
return schema_definition_error(
path + ".type",
"schema type array cannot be empty",
)
}
let mut matched = false
for type_value in type_values {
guard type_value is String(type_name) else {
return schema_definition_error(
path + ".type",
"schema type array must contain strings",
)
}
if !known_schema_type(type_name) {
return schema_definition_error(
path + ".type",
"schema type is not supported",
)
}
if matches_schema_type(value, type_name) {
matched = true
}
}
if !matched {
return schema_violation(path, "value does not match any schema type")
}
Ok(())
}
Some(_) =>
schema_definition_error(
path + ".type",
"schema type must be a string or array",
)
}
}
///|
fn known_schema_type(type_name : String) -> Bool {
match type_name {
"null" | "boolean" | "object" | "array" | "number" | "integer" | "string" =>
true
_ => false
}
}
///|
fn matches_schema_type(value : Json, type_name : String) -> Bool {
match type_name {
"null" => value is Null
"boolean" => value is True || value is False
"object" => value is Object(_)
"array" => value is Array(_)
"number" => value is Number(_, ..)
"integer" => {
guard value is Number(number, ..) else { return false }
number.trunc() == number
}
"string" => value is String(_)
_ => false
}
}
///|
fn validate_enum_keyword(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
match schema.get("enum") {
None => Ok(())
Some(Array(values)) => {
if values.length() == 0 {
return schema_definition_error(
path + ".enum",
"enum must contain at least one value",
)
}
for candidate in values {
if Json::equal(value, candidate) {
return Ok(())
}
}
schema_violation(path, "value is not included in enum")
}
Some(_) => schema_definition_error(path + ".enum", "enum must be an array")
}
}
///|
fn validate_const_keyword(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
match schema.get("const") {
None => Ok(())
Some(expected) =>
if Json::equal(value, expected) {
Ok(())
} else {
schema_violation(path, "value does not match const")
}
}
}
///|
fn validate_all_of(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
match schema.get("allOf") {
None => Ok(())
Some(Array(schemas)) => {
for child_schema in schemas {
match validate_schema_at(value, child_schema, path) {
Ok(_) => ()
Err(diagnostics) => return Err(diagnostics)
}
}
Ok(())
}
Some(_) =>
schema_definition_error(path + ".allOf", "allOf must be an array")
}
}
///|
fn validate_any_of(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
match schema.get("anyOf") {
None => Ok(())
Some(Array(schemas)) => {
for child_schema in schemas {
match schema_accepts(value, child_schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(true) => return Ok(())
Ok(false) => ()
}
}
schema_violation(path, "value does not satisfy anyOf")
}
Some(_) =>
schema_definition_error(path + ".anyOf", "anyOf must be an array")
}
}
///|
fn validate_one_of(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
match schema.get("oneOf") {
None => Ok(())
Some(Array(schemas)) => {
let mut matches = 0
for child_schema in schemas {
match schema_accepts(value, child_schema, path) {
Err(diagnostics) => return Err(diagnostics)
Ok(true) => matches = matches + 1
Ok(false) => ()
}
}
if matches == 1 {
Ok(())
} else {
schema_violation(path, "value must satisfy exactly one oneOf schema")
}
}
Some(_) =>
schema_definition_error(path + ".oneOf", "oneOf must be an array")
}
}
///|
fn validate_not(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
match schema.get("not") {
None => Ok(())
Some(child_schema) =>
match schema_accepts(value, child_schema, path) {
Err(diagnostics) => Err(diagnostics)
Ok(true) => schema_violation(path, "value must not satisfy not schema")
Ok(false) => Ok(())
}
}
}
///|
fn schema_accepts(
value : Json,
schema : Json,
path : String,
) -> Result[Bool, Array[Diagnostic]] {
match validate_schema_at(value, schema, path) {
Ok(_) => Ok(true)
Err(diagnostics) => {
for item in diagnostics {
if item.code is UnsupportedSchema {
return Err(diagnostics)
}
}
Ok(false)
}
}
}
///|
fn validate_string_keywords(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
guard value is String(text) else { return Ok(()) }
let min_length = match read_nonnegative_integer(schema, "minLength", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
let max_length = match read_nonnegative_integer(schema, "maxLength", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
if min_length is Some(minimum) && text.length() < minimum {
return schema_violation(path, "string is shorter than minLength")
}
if max_length is Some(maximum) && text.length() > maximum {
return schema_violation(path, "string is longer than maxLength")
}
Ok(())
}
///|
fn validate_number_keywords(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
guard value is Number(number, ..) else { return Ok(()) }
let minimum = match read_number(schema, "minimum", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
let maximum = match read_number(schema, "maximum", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
let exclusive_minimum = match read_number(schema, "exclusiveMinimum", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
let exclusive_maximum = match read_number(schema, "exclusiveMaximum", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
if minimum is Some(bound) && number < bound {
return schema_violation(path, "number is below minimum")
}
if maximum is Some(bound) && number > bound {
return schema_violation(path, "number is above maximum")
}
if exclusive_minimum is Some(bound) && number <= bound {
return schema_violation(path, "number does not exceed exclusiveMinimum")
}
if exclusive_maximum is Some(bound) && number >= bound {
return schema_violation(path, "number is not below exclusiveMaximum")
}
Ok(())
}
///|
fn validate_array_keywords(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
guard value is Array(values) else { return Ok(()) }
let min_items = match read_nonnegative_integer(schema, "minItems", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
let max_items = match read_nonnegative_integer(schema, "maxItems", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
if min_items is Some(minimum) && values.length() < minimum {
return schema_violation(path, "array has fewer items than minItems")
}
if max_items is Some(maximum) && values.length() > maximum {
return schema_violation(path, "array has more items than maxItems")
}
match schema.get("uniqueItems") {
Some(True) =>
for index, value in values {
for previous_index in 0.. ()
Some(_) =>
return schema_definition_error(
path + ".uniqueItems",
"uniqueItems must be a boolean",
)
}
match schema.get("items") {
None => Ok(())
Some(Array(_)) =>
schema_definition_error(
path + ".items",
"tuple-style items arrays are not supported",
)
Some(item_schema) => {
for index, item in values {
match
validate_schema_at(
item,
item_schema,
path + "[" + index.to_string() + "]",
) {
Ok(_) => ()
Err(diagnostics) => return Err(diagnostics)
}
}
Ok(())
}
}
}
///|
fn validate_object_keywords(
value : Json,
schema : Map[String, Json],
path : String,
) -> Result[Unit, Array[Diagnostic]] {
guard value is Object(properties) else { return Ok(()) }
let min_properties = match
read_nonnegative_integer(schema, "minProperties", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
let max_properties = match
read_nonnegative_integer(schema, "maxProperties", path) {
Err(diagnostics) => return Err(diagnostics)
Ok(value) => value
}
if min_properties is Some(minimum) && properties.length() < minimum {
return schema_violation(
path, "object has fewer properties than minProperties",
)
}
if max_properties is Some(maximum) && properties.length() > maximum {
return schema_violation(
path, "object has more properties than maxProperties",
)
}
let property_schemas : Map[String, Json] = match schema.get("properties") {
None => Map([])
Some(Object(value)) => value
Some(_) =>
return schema_definition_error(
path + ".properties",
"properties must be an object",
)
}
match schema.get("required") {
None => ()
Some(Array(required_names)) =>
for required_value in required_names {
guard required_value is String(name) else {
return schema_definition_error(
path + ".required",
"required must contain strings",
)
}
if properties.get(name) is None {
return schema_violation(
path + "." + name,
"required property is missing",
)
}
}
Some(_) =>
return schema_definition_error(
path + ".required",
"required must be an array",
)
}
for name, property_value in properties {
match property_schemas.get(name) {
Some(property_schema) =>
match
validate_schema_at(property_value, property_schema, path + "." + name) {
Ok(_) => ()
Err(diagnostics) => return Err(diagnostics)
}
None =>
match schema.get("additionalProperties") {
None | Some(True) => ()
Some(False) =>
return schema_violation(
path + "." + name,
"additional property is not allowed",
)
Some(additional_schema) =>
match
validate_schema_at(
property_value,
additional_schema,
path + "." + name,
) {
Ok(_) => ()
Err(diagnostics) => return Err(diagnostics)
}
}
}
}
Ok(())
}
///|
fn read_number(
schema : Map[String, Json],
keyword : String,
path : String,
) -> Result[Double?, Array[Diagnostic]] {
match schema.get(keyword) {
None => Ok(None)
Some(Number(value, ..)) => Ok(Some(value))
Some(_) =>
Err([
diagnostic(
path + "." + keyword,
UnsupportedSchema,
keyword + " must be a number",
),
])
}
}
///|
fn read_nonnegative_integer(
schema : Map[String, Json],
keyword : String,
path : String,
) -> Result[Int?, Array[Diagnostic]] {
match schema.get(keyword) {
None => Ok(None)
Some(Number(value, ..)) => {
let integer = value.to_int()
if value != integer.to_double() || integer < 0 {
return Err([
diagnostic(
path + "." + keyword,
UnsupportedSchema,
keyword + " must be a non-negative integer",
),
])
}
Ok(Some(integer))
}
Some(_) =>
Err([
diagnostic(
path + "." + keyword,
UnsupportedSchema,
keyword + " must be a non-negative integer",
),
])
}
}
///|
fn schema_violation(
path : String,
message : String,
) -> Result[Unit, Array[Diagnostic]] {
Err([diagnostic(path, SchemaViolation, message)])
}
///|
fn schema_definition_error(
path : String,
message : String,
) -> Result[Unit, Array[Diagnostic]] {
Err([diagnostic(path, UnsupportedSchema, message)])
}