// The standard library currently deprecates its depth-limit option. Keep the explicit bound until a replacement is available.
///|
#warnings("-deprecated")
fn parse_document(
text : String,
side : String,
diagnostics : Array[Diagnostic],
) -> Json? {
let root = @json.parse(text, max_nesting_depth=128) catch {
error => {
diagnostics.push({
code: "invalid-json",
severity: "error",
side,
http_method: "",
path: "",
location: { pointer: "", definition_pointer: "", },
reason: error.to_string(),
})
return None
}
}
let ctx : Context = {
root,
side,
http_method: "",
path: "",
diagnostics,
budget: new_budget(),
}
let root_node = node(root, "")
guard root is Object(_) else {
ctx.diagnose(
"invalid-document", "error", root_node, "The document must be an object.",
)
return None
}
validate_keys(ctx, root_node, [
"openapi", "info", "paths", "components", "servers", "security", "tags", "externalDocs",
])
if string_value(field(root, "openapi")) != Some("3.0.3") {
ctx.diagnose(
"unsupported-version", "error", root_node, "Expected OpenAPI 3.0.3 JSON.",
)
return None
}
match field(root, "info") {
Some(Object(info)) =>
if string_value(info.get("title")) is None ||
string_value(info.get("version")) is None {
ctx.diagnose(
"invalid-info", "error", root_node, "info.title and info.version must be strings.",
)
}
_ =>
ctx.diagnose(
"invalid-info", "error", root_node, "The info object is required.",
)
}
if !(field(root, "paths") is Some(Object(_))) {
ctx.diagnose(
"invalid-paths", "error", root_node, "The paths object is required.",
)
return None
}
Some(root)
}
// RFC 6901 decoding: ~1 must be decoded before ~0.
///|
fn decode_pointer_token(value : String) -> String? {
let chars = value.to_array()
let mut index = 0
while index < chars.length() {
if chars[index] == '~' {
if index + 1 >= chars.length() ||
(chars[index + 1] != '0' && chars[index + 1] != '1') {
return None
}
index += 1
}
index += 1
}
Some(value.replace_all(old="~1", new="/").replace_all(old="~0", new="~"))
}
///|
fn Context::resolve(self : Context, value : Node) -> Node? {
let ref_value = field(value.raw, "$ref")
guard ref_value is Some(raw_ref) else { return Some(value) }
guard raw_ref is String(raw_reference) else {
self.diagnose("invalid-reference", "error", value, "$ref must be a string.")
return None
}
if !raw_reference.has_prefix("#") {
self.diagnose(
"external-reference",
"unsupported",
value,
"Only same-document JSON Pointer references are supported: " +
raw_reference,
)
return None
}
guard decode_fragment(raw_reference) is Some(reference) else {
self.diagnose(
"invalid-reference",
"error",
value,
"Invalid percent encoding in reference: " + raw_reference,
)
return None
}
if !reference.has_prefix("#/") && reference != "#" {
self.diagnose(
"unsupported-reference", "unsupported", value, "Same-document references must use JSON Pointers.",
)
return None
}
if value.refs.contains(reference) || value.refs.length() >= 64 {
self.diagnose(
"cyclic-reference",
"unsupported",
value,
"Reference cycle or reference depth limit (64): " + reference,
)
return None
}
for key in keys(value.raw) {
if key != "$ref" {
self.diagnose(
"reference-sibling",
"unsupported",
value,
"Reference siblings are outside V1: " + key,
)
}
}
let mut target = self.root
let mut pointer = ""
let mut first = true
for part in reference.split("/") {
if first {
first = false
continue
}
guard decode_pointer_token(part.to_owned()) is Some(token) else {
self.diagnose(
"invalid-reference",
"error",
value,
"Invalid JSON Pointer escape: " + reference,
)
return None
}
guard pointer_member(target, token) is Some(next) else {
self.diagnose(
"missing-reference",
"error",
value,
"Reference target does not exist: " + reference,
)
return None
}
target = next
pointer += "/" + pointer_token(token)
}
let refs = value.refs.copy()
refs.push(reference)
self.resolve({
raw: target,
location: { pointer: value.location.pointer, definition_pointer: pointer, },
refs,
})
}
///|
fn hex_digit(byte : Byte) -> Int? {
let value = byte.to_int()
if value >= 48 && value <= 57 {
Some(value - 48)
} else if value >= 65 && value <= 70 {
Some(value - 55)
} else if value >= 97 && value <= 102 {
Some(value - 87)
} else {
None
}
}
///|
fn decode_fragment(value : String) -> String? {
let bytes = @utf8.encode(value)
let result : Array[Byte] = []
let mut index = 0
while index < bytes.length() {
if bytes[index] == b'%' {
if index + 2 >= bytes.length() {
return None
}
guard hex_digit(bytes[index + 1]) is Some(high) &&
hex_digit(bytes[index + 2]) is Some(low) else {
return None
}
result.push((high * 16 + low).to_byte())
index += 3
} else {
result.push(bytes[index])
index += 1
}
}
Some(@utf8.decode(Bytes::from_array(result))) catch {
_ => None
}
}
///|
fn pointer_member(value : Json, token : String) -> Json? {
match value {
Object(fields) => fields.get(token)
Array(items) => {
if token == "" || (token.has_prefix("0") && token != "0") {
return None
}
let mut index = 0
for char in token.to_array() {
if char < '0' || char > '9' || index > items.length() {
return None
}
index = index * 10 + char.to_int() - 48
}
items.get(index)
}
_ => None
}
}
// Stable canonical form keeps property names intact (including names such as "description").
///|
fn canonical(value : Json) -> String {
match value {
Object(fields) => {
let parts : Array[String] = []
for key in keys(value) {
parts.push(key.to_json().stringify() + ":" + canonical(fields[key]))
}
"{" + parts.join(",") + "}"
}
Array(items) => "[" + items.map(canonical).join(",") + "]"
_ => value.stringify()
}
}
///|
fn ignored_key(key : String) -> Bool {
[
"description", "summary", "title", "example", "examples", "externalDocs", "deprecated",
].contains(key) ||
key.has_prefix("x-")
}
///|
fn extra_contract(value : Json, handled : Array[String]) -> Json {
let result : Map[String, Json] = Map([])
if value is Object(fields) {
for key, item in fields {
if !handled.contains(key) && !ignored_key(key) {
result[key] = item
}
}
}
Json::object(result)
}
///|
fn compare_extra(
ctx : Context,
old : Node,
new : Node,
handled : Array[String],
) -> Unit {
if canonical(extra_contract(old.raw, handled)) !=
canonical(extra_contract(new.raw, handled)) {
ctx.diagnose(
"unanalysed-contract-change", "unsupported", new, "Contract fields outside the V1 rules changed; manual review is required.",
)
}
}