///|
fn add_finding(
findings : Array[Finding],
ctx : Context,
rule_id : String,
old : Node?,
new : Node?,
reason : String,
) -> Unit {
findings.push({
rule_id,
http_method: ctx.http_method,
path: ctx.path,
old_location: old.map(fn(n) { n.location }),
new_location: new.map(fn(n) { n.location }),
reason,
})
}
///|
fn compare_schema(
old_ctx : Context,
new_ctx : Context,
old_raw : Node,
new_raw : Node,
findings : Array[Finding],
depth : Int,
) -> Unit {
guard new_ctx.visit(new_raw) else { return }
if depth > 64 {
return
} // The validation pass reports the depth limit.
guard old_ctx.resolve(old_raw) is Some(old) &&
new_ctx.resolve(new_raw) is Some(new) else {
return
}
if !(old.raw is Object(_)) || !(new.raw is Object(_)) {
return
}
let old_kind = string_value(field(old.raw, "type"))
let new_kind = string_value(field(new.raw, "type"))
compare_extra(new_ctx, old, new, [
"properties", "required", "enum", "default", "xml",
])
if old_kind == Some("string") && new_kind == Some("string") {
let old_values = string_set(old_ctx, old, "enum")
let new_values = string_set(new_ctx, new, "enum")
if !new_values.is_empty() {
if field(old.raw, "enum") is None {
add_finding(
findings,
new_ctx,
"request-enum-value-removed",
Some(old),
Some(new),
"Previously unrestricted string values are restricted to: " +
new_values.join(", ") +
".",
)
} else {
let removed = old_values.filter(fn(v) { !new_values.contains(v) })
if !removed.is_empty() {
add_finding(
findings,
new_ctx,
"request-enum-value-removed",
Some(old),
Some(new),
"Previously allowed string values are no longer accepted: " +
removed.join(", ") +
".",
)
}
}
}
} else if canonical(field(old.raw, "enum").unwrap_or(Json::null())) !=
canonical(field(new.raw, "enum").unwrap_or(Json::null())) {
new_ctx.diagnose(
"unanalysed-enum-change", "unsupported", new, "Enum analysis requires string schemas on both sides.",
)
}
if old_kind != Some("object") || new_kind != Some("object") {
return
}
let old_required = string_set(old_ctx, old, "required")
let new_required = string_set(new_ctx, new, "required")
let old_props = properties(old)
let new_props = properties(new)
for name in new_required {
if !old_required.contains(name) {
let new_property = new_props.get(name)
let read_only = match new_property {
Some(p) =>
match new_ctx.resolve(p) {
Some(p) => is_true(field(p.raw, "readOnly"))
None => false
}
None => false
}
if !read_only {
add_finding(
findings,
new_ctx,
"request-required-property-added",
Some(old_props.get(name).unwrap_or(old)),
Some(new_property.unwrap_or(new)),
"Request property '" +
name +
"' is now required. A default does not remove the required constraint.",
)
}
}
}
let names = old_props.keys().to_array()
names.sort()
for name in names {
match new_props.get(name) {
Some(new_property) =>
compare_schema(
old_ctx,
new_ctx,
old_props[name],
new_property,
findings,
depth + 1,
)
None =>
new_ctx.diagnose(
"unanalysed-property-removal",
"unsupported",
new,
"Removed request property constraints require manual review: " + name,
)
}
}
}
///|
fn compare_operation(
old : Operation,
new : Operation,
findings : Array[Finding],
) -> Unit {
compare_extra(new.ctx, old.path_item, new.path_item, [
"parameters", "delete", "get", "head", "options", "patch", "post", "put", "trace",
])
compare_extra(new.ctx, old.value, new.value, [
"parameters", "requestBody", "responses", "operationId", "tags",
])
let old_responses = resolved_response(
old.ctx,
old.value.child(
"responses",
field(old.value.raw, "responses").unwrap_or(Json::null()),
),
true,
0,
)
let new_responses = resolved_response(
new.ctx,
new.value.child(
"responses",
field(new.value.raw, "responses").unwrap_or(Json::null()),
),
true,
0,
)
if !new.ctx.budget.exhausted &&
canonical(old_responses) != canonical(new_responses) {
new.ctx.diagnose(
"unanalysed-response-change",
"unsupported",
new.value,
"Response contracts changed; V1 checks request compatibility only.",
)
}
let names = new.parameters.keys().to_array()
names.sort()
for key in names {
let parameter = new.parameters[key]
let previous = old.parameters.get(key)
if is_true(field(parameter.raw, "required")) {
let previously_required = match previous {
Some(p) => is_true(field(p.raw, "required"))
None => false
}
if !previously_required {
add_finding(
findings,
new.ctx,
"request-required-parameter-added",
previous,
Some(parameter),
"Request parameter '" + key + "' is now required.",
)
}
}
if previous is Some(previous) {
compare_extra(new.ctx, previous, parameter, [
"schema", "required", "name", "in",
])
match (field(previous.raw, "schema"), field(parameter.raw, "schema")) {
(Some(a), Some(b)) =>
compare_schema(
old.ctx,
new.ctx,
previous.child("schema", a),
parameter.child("schema", b),
findings,
0,
)
(None, None) => ()
_ =>
new.ctx.diagnose(
"unanalysed-parameter-change", "unsupported", parameter, "Parameter schema availability changed.",
)
}
}
}
// Removing parameter validation usually widens accepted requests, but serialization and server handling are outside V1.
for key, previous in old.parameters {
if !new.parameters.contains(key) {
new.ctx.diagnose(
"unanalysed-parameter-removal",
"unsupported",
previous,
"Removed parameter needs manual review: " + key,
)
}
}
match (old.body_object, new.body_object) {
(Some(a), Some(b)) => compare_extra(new.ctx, a, b, ["content"])
(None, None) => ()
_ =>
new.ctx.diagnose(
"unanalysed-request-body-change",
"unsupported",
new.value,
"Request body was added or removed; manual review is required.",
)
}
match (old.body, new.body) {
(Some(a), Some(b)) => compare_schema(old.ctx, new.ctx, a, b, findings, 0)
(None, None) => ()
_ =>
new.ctx.diagnose(
"unanalysed-request-body-change",
"unsupported",
new.value,
"JSON request schema availability changed.",
)
}
}
///|
/// Compare local OpenAPI 3.0.3 JSON using the explicitly documented V1 profile.
pub fn check_json(old_text : String, new_text : String) -> Report {
let diagnostics : Array[Diagnostic] = []
let findings : Array[Finding] = []
let budget = new_budget()
let old_root = parse_document(old_text, "old", diagnostics)
let new_root = parse_document(new_text, "new", diagnostics)
let old_operations = match old_root {
Some(root) => collect_operations(root, "old", diagnostics, budget)
None => Map([])
}
let new_operations = match new_root {
Some(root) => collect_operations(root, "new", diagnostics, budget)
None => Map([])
}
if old_root is Some(a) && new_root is Some(b) {
let ctx : Context = {
root: b,
side: "new",
http_method: "",
path: "",
diagnostics,
budget,
}
compare_extra(ctx, node(a, ""), node(b, ""), [
"openapi", "info", "paths", "components", "tags",
])
compare_extra(
ctx,
node(
field(a, "components").unwrap_or(Json::empty_object()),
"/components",
),
node(
field(b, "components").unwrap_or(Json::empty_object()),
"/components",
),
[
"schemas", "parameters", "requestBodies", "responses", "headers", "examples",
"links", "callbacks",
],
)
let names = old_operations.keys().to_array()
names.sort()
for key in names {
let old = old_operations[key]
match new_operations.get(key) {
Some(new) => compare_operation(old, new, findings)
None =>
add_finding(
findings,
old.ctx,
"operation-removed",
Some(old.value),
None,
"The previously available HTTP operation no longer exists.",
)
}
}
}
findings.sort_by_key(fn(f) {
f.path +
"\n" +
f.http_method +
"\n" +
f.rule_id +
"\n" +
ToJson::to_json(f).stringify()
})
diagnostics.sort_by_key(fn(d) {
d.path +
"\n" +
d.http_method +
"\n" +
d.side +
"\n" +
d.location.pointer +
"\n" +
d.code +
"\n" +
d.reason
})
let unique : Array[Diagnostic] = []
for d in diagnostics {
if !unique.contains(d) {
unique.push(d)
}
}
let status = if unique.any(fn(d) { d.severity == "error" }) {
"invalid"
} else if !unique.is_empty() {
"incomplete"
} else {
"complete"
}
{ status, findings, diagnostics: unique, }
}