///|
/// An individual issue found during JSON Schema validation.
pub struct ValidationIssue {
path : @json.JsonPath
message : String
} derive(Debug, Eq)
///|
pub fn ValidationIssue::ValidationIssue(
path~ : @json.JsonPath,
message~ : String,
) -> ValidationIssue {
{ path, message }
}
///|
pub impl Show for ValidationIssue with fn output(
self : ValidationIssue,
logger : &Logger,
) -> Unit {
logger.write_string("\{self.path}: \{self.message}")
}
///|
pub impl ToJson for ValidationIssue with fn to_json(self : ValidationIssue) -> Json {
Json::object({
"path": Json::string(self.path.to_string()),
"message": Json::string(self.message),
})
}
///|
/// Validation failure holding one or more validation issues.
pub suberror ValidationIssues {
ValidationIssues(Array[ValidationIssue])
} derive(Debug, Eq)
///|
pub impl Show for ValidationIssues with fn output(
self : ValidationIssues,
logger : &Logger,
) -> Unit {
match self {
ValidationIssues(issues) =>
for i, issue in issues {
if i > 0 {
logger.write_string("\n")
}
logger.write_string(Show::to_string(issue))
}
}
}
///|
pub impl ToJson for ValidationIssues with fn to_json(self : ValidationIssues) -> Json {
match self {
ValidationIssues(issues) =>
issues.map(fn(i) { ToJson::to_json(i) }).to_json()
}
}
///|
priv struct ValidationContext {
defs : Map[String, Schema]
max_depth : Int
issues : Array[ValidationIssue]
}
///|
fn ValidationContext::push_issue(
self : ValidationContext,
path : @json.JsonPath,
message : String,
) -> Unit {
self.issues.push(ValidationIssue(path~, message~))
}
///|
priv struct PathWitness {
path : @json.JsonPath
}
///|
impl @json.FromJson for PathWitness with fn from_json(
_json : Json,
path : @json.JsonPath,
) -> PathWitness {
{ path, }
}
///|
fn root_json_path() -> @json.JsonPath {
let witness : PathWitness = try! @json.from_json(Json::null())
witness.path
}
///|
fn validate_document_syntax(value : Json) -> Unit raise SchemaError {
if value is (True | False) {
return
}
guard value is Object(fields) else {
raise InvalidDocument("a JSON Schema document must be an object or boolean")
}
match fields.get("$schema") {
Some(String(dialect)) if dialect == DRAFT_2020_12 => ()
Some(String(dialect)) => raise UnsupportedDialect(dialect)
Some(_) => raise InvalidDocument("$schema must be a string")
None => ()
}
if fields.get("$id") is Some(id) && !(id is String(_)) {
raise InvalidDocument("$id must be a string")
}
if fields.get("$ref") is Some(reference) && !(reference is String(_)) {
raise InvalidDocument("$ref must be a string")
}
validate_references(value, depth=0, max_depth=128)
}
///|
fn validate_document_compilation(
value : Json,
max_depth~ : Int,
max_nodes~ : Int,
) -> Unit raise SchemaError {
validate_document_syntax(value)
let nodes = count_schema_nodes(value, depth=0, max_depth~)
guard nodes <= max_nodes else {
raise InvalidSchema("schema exceeds max_nodes")
}
validate_references(value, depth=0, max_depth~)
validate_supported_keywords(value, depth=0, max_depth~)
validate_keyword_shapes(value, depth=0, max_depth~)
}
///|
fn count_schema_nodes(
value : Json,
depth~ : Int,
max_depth~ : Int,
) -> Int raise SchemaError {
guard depth <= max_depth else {
raise InvalidSchema("schema exceeds max_depth")
}
match value {
Object(fields) =>
fields
.values()
.fold(init=1, (acc, child) => {
acc + count_schema_nodes(child, depth=depth + 1, max_depth~)
})
Array(values) =>
values.fold(init=1, (acc, child) => {
acc + count_schema_nodes(child, depth=depth + 1, max_depth~)
})
_ => 1
}
}
///|
fn validate_references(
value : Json,
depth~ : Int,
max_depth~ : Int,
) -> Unit raise SchemaError {
guard depth <= max_depth else {
raise InvalidSchema("schema exceeds max_depth")
}
guard value is Object(fields) else { return }
for ref_key in ["$ref", "$dynamicRef"] {
guard fields.get(ref_key) is Some(ref_val) else { continue }
guard ref_val is String(reference) else {
raise InvalidSchema("JSON Schema \{ref_key} must be a string")
}
guard reference.has_prefix("#") else {
raise InvalidSchema("network JSON Schema references are disabled")
}
}
for child in schema_children(fields) {
validate_references(child, depth=depth + 1, max_depth~)
}
}
///|
/// The portable validator rejects keywords it cannot evaluate instead of
/// silently accepting instances that were never checked.
fn validate_supported_keywords(
value : Json,
depth~ : Int,
max_depth~ : Int,
) -> Unit raise SchemaError {
guard depth <= max_depth else {
raise InvalidSchema("schema exceeds max_depth")
}
match value {
Object(fields) => {
for
keyword in [
"pattern", "contains", "prefixItems", "patternProperties", "propertyNames",
"dependentRequired", "dependentSchemas", "if", "then", "else", "unevaluatedItems",
"unevaluatedProperties", "format", "contentEncoding", "contentMediaType",
"$dynamicRef",
] {
if fields.contains(keyword) {
raise UnsupportedKeyword(keyword)
}
}
for child in schema_children(fields) {
validate_supported_keywords(child, depth=depth + 1, max_depth~)
}
}
Array(_) => ()
_ => ()
}
}
///|
/// Validate the shapes of keywords understood by the bounded evaluator. This
/// keeps malformed schemas out of `CompiledSchema`; instance validation can
/// therefore focus on instances rather than reinterpreting schema syntax.
fn validate_keyword_shapes(
value : Json,
depth~ : Int,
max_depth~ : Int,
) -> Unit raise SchemaError {
guard depth <= max_depth else {
raise InvalidSchema("schema exceeds max_depth")
}
match value {
Object(fields) => {
match fields.get("type") {
Some(String(_)) | None => ()
Some(_) => raise InvalidSchema("schema type must be a string")
}
match fields.get("enum") {
Some(Array(values)) if !values.is_empty() => ()
Some(Array(_)) => raise InvalidSchema("schema enum must not be empty")
Some(_) => raise InvalidSchema("schema enum must be an array")
None => ()
}
for
keyword in [
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
] {
match fields.get(keyword) {
Some(Number(value, ..)) if !value.is_nan() && !value.is_inf() => ()
Some(_) => raise InvalidSchema("\{keyword} must be a finite number")
None => ()
}
}
for keyword in ["minLength", "maxLength"] {
match fields.get(keyword) {
Some(Number(value, ..)) if !value.is_nan() &&
!value.is_inf() &&
value >= 0.0 &&
value == value.to_int().to_double() => ()
Some(_) =>
raise InvalidSchema("\{keyword} must be a non-negative integer")
None => ()
}
}
match fields.get("multipleOf") {
Some(Number(value, ..)) if !value.is_nan() &&
!value.is_inf() &&
value > 0.0 => ()
Some(_) => raise InvalidSchema("multipleOf must be a positive number")
None => ()
}
for keyword in ["minItems", "maxItems", "minProperties", "maxProperties"] {
match fields.get(keyword) {
Some(Number(value, ..)) if !value.is_nan() &&
!value.is_inf() &&
value >= 0.0 &&
value == value.to_int().to_double() => ()
Some(_) =>
raise InvalidSchema("\{keyword} must be a non-negative integer")
None => ()
}
}
for keyword in ["allOf", "anyOf", "oneOf"] {
match fields.get(keyword) {
Some(Array(values)) if keyword == "allOf" || !values.is_empty() => ()
Some(Array(_)) => raise InvalidSchema("\{keyword} must not be empty")
Some(_) => raise InvalidSchema("\{keyword} must be an array")
None => ()
}
}
match fields.get("required") {
Some(Array(names)) => {
let seen : Map[String, Bool] = Map([])
for name in names {
guard name is String(name_str) else {
raise InvalidSchema("required names must be strings")
}
guard !seen.contains(name_str) else {
raise InvalidSchema("required property names must be unique")
}
seen[name_str] = true
}
}
Some(_) => raise InvalidSchema("required must be an array")
None => ()
}
match fields.get("properties") {
Some(Object(_)) => ()
Some(_) => raise InvalidSchema("properties must be an object")
None => ()
}
match fields.get("additionalProperties") {
Some(True | False) | None => ()
Some(_) =>
raise InvalidSchema("additionalProperties schemas are unsupported")
}
match fields.get("items") {
Some(Object(_) | True | False) | None => ()
Some(_) => raise InvalidSchema("items must be a schema")
}
for child in schema_children(fields) {
validate_keyword_shapes(child, depth=depth + 1, max_depth~)
}
}
Array(_) => ()
_ => ()
}
}
///|
/// Return only values that are themselves schemas. Values of `const`, `enum`,
/// and annotations are instance data and must not be interpreted as schemas.
fn schema_children(fields : Map[String, Json]) -> Array[Json] {
let children : Array[Json] = []
for key in ["properties", "$defs", "definitions", "dependentSchemas"] {
guard fields.get(key) is Some(Object(fields)) else { continue }
for _, child in fields {
children.push(child)
}
}
for key in ["allOf", "anyOf", "oneOf"] {
guard fields.get(key) is Some(Array(values)) else { continue }
for child in values {
children.push(child)
}
}
for
key in [
"not", "items", "contains", "propertyNames", "if", "then", "else", "unevaluatedItems",
"unevaluatedProperties",
] {
guard fields.get(key) is Some(child) && child is (Object(_) | True | False) else {
continue
}
children.push(child)
}
children
}
///|
fn collect_definitions(
value : Json,
defs~ : Map[String, Schema],
pointer~ : String,
depth~ : Int,
max_depth~ : Int,
) -> Unit raise SchemaError {
guard depth <= max_depth else {
raise InvalidSchema("schema exceeds max_depth")
}
defs[pointer] = parse_schema_json(value)
if value is Object(fields) {
for k, v in fields {
let encoded = k
.replace_all(old="~", new="~0")
.replace_all(old="/", new="~1")
let child_pointer = if pointer == "#" {
"#/" + encoded
} else {
pointer + "/" + encoded
}
if v is (Object(_) | True | False) {
collect_definitions(
v,
defs~,
pointer=child_pointer,
depth=depth + 1,
max_depth~,
)
}
}
}
}
///|
fn parse_schema_json(value : Json) -> Schema raise SchemaError {
match value {
True => Schema::from_node(BooleanSchema::always())
False => Schema::from_node(BooleanSchema::never())
Object(fields) => {
if fields is { "$ref": String(reference), .. } {
return Schema::from_node(RefSchema(reference))
}
if fields is { "const": val, .. } {
return Schema::from_node(ConstSchema::from_value(val))
}
if fields is { "enum": Array(values), .. } {
return Schema::from_node(EnumSchema(values))
}
if fields is { "allOf": Array(values), .. } {
let schemas = values.map_raise(parse_schema_json)
return Schema::from_node(AllOfSchema(schemas))
}
if fields is { "anyOf": Array(values), .. } {
let schemas = values.map_raise(parse_schema_json)
return Schema::from_node(AnyOfSchema(schemas))
}
if fields is { "oneOf": Array(values), .. } {
let schemas = values.map_raise(parse_schema_json)
return Schema::from_node(OneOfSchema(schemas))
}
if fields is { "not": v, .. } {
return Schema::from_node(NotSchema(parse_schema_json(v)))
}
let desc = fields.get_string("description")
let type_name = fields.get_string("type")
match type_name {
Some("null") => Schema::from_node(NullSchema(description?=desc))
Some("boolean") => Schema::from_node(BoolTypeSchema(description?=desc))
Some("string") => {
let min_length = fields.get_number("minLength").map(n => n.to_int())
let max_length = fields.get_number("maxLength").map(n => n.to_int())
Schema::from_node(
StringSchema(min_length?, max_length?, description?=desc),
)
}
Some("integer") => {
let min = fields.get_number("minimum").map(n => n.to_int())
let max = fields.get_number("maximum").map(n => n.to_int())
Schema::from_node(
IntegerSchema(minimum?=min, maximum?=max, description?=desc),
)
}
Some("number") => {
let min = fields.get_number("minimum")
let max = fields.get_number("maximum")
let ex_min = fields.get_number("exclusiveMinimum")
let ex_max = fields.get_number("exclusiveMaximum")
let mult = fields.get_number("multipleOf")
Schema::from_node(
NumberSchema(
minimum?=min,
maximum?=max,
exclusive_minimum?=ex_min,
exclusive_maximum?=ex_max,
multiple_of?=mult,
description?=desc,
),
)
}
Some("array") => {
let items = fields.get("items").map(parse_schema_json)
let min = fields.get_number("minItems").map(n => n.to_int())
let max = fields.get_number("maxItems").map(n => n.to_int())
let unique = fields is { "uniqueItems": True, .. }
Schema::from_node(
ArraySchema(
items?,
min_items?=min,
max_items?=max,
unique_items=unique,
description?=desc,
),
)
}
Some("object") => {
let properties : Map[String, Schema] = if fields
is { "properties": Object(props), .. } {
props.map_raise((_, v) => parse_schema_json(v))
} else {
Map([])
}
let required : Array[String] = if fields
is { "required": Array(reqs), .. } {
reqs.filter_map(r => if r is String(s) { Some(s) } else { None })
} else {
[]
}
let add_props = !(fields is { "additionalProperties": False, .. })
let min = fields.get_number("minProperties").map(n => n.to_int())
let max = fields.get_number("maxProperties").map(n => n.to_int())
if !properties.is_empty() ||
!required.is_empty() ||
!add_props ||
min is Some(_) ||
max is Some(_) {
Schema::from_node(
ObjectSchema(
properties~,
required~,
additional_properties=add_props,
min_properties?=min,
max_properties?=max,
description?=desc,
),
)
} else {
Schema::from_node(BooleanSchema::always())
}
}
None => Schema::from_node(BooleanSchema::always())
_ => Schema::from_node(RawSchema(value))
}
}
_ => raise InvalidDocument("schema must be an object or boolean")
}
}