detect scalar request narrowing

This commit is contained in:
Adam Perry
2026-06-25 14:48:02 +00:00
parent 6a7e286383
commit 3944fc1292
12 changed files with 804 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
mod value;
use crate::ApiSchema;
use crate::Arguments;
use crate::Location;
use crate::Method;
use crate::SchemaId;
use crate::SchemaNode;
use crate::SchemaPath;
use crate::SchemaRules;
use crate::SchemaSnapshot;
use crate::Violation;
use anyhow::Result;
use std::collections::BTreeMap;
use std::collections::HashSet;
pub(crate) fn compare_api_schemas(base: &ApiSchema, current: &ApiSchema) -> Result<Vec<Violation>> {
let mut retained_methods = 0;
let mut violations = Vec::new();
for (name, base_method) in &base.methods {
let Some(current_method) = current.methods.get(name) else {
violations.push(Violation::MethodRemoved {
method: name.clone(),
});
continue;
};
retained_methods += 1;
let mut cx = CompareCx::new(base, current, name);
base_method.compare(current_method, &mut cx, &SchemaPath::default())?;
violations.extend(cx.violations);
}
sort_and_dedup(&mut violations);
Ok(collapse_shared_envelope_changes(
violations,
retained_methods,
))
}
/// Recursively compares typed schema nodes and records values accepted by the base only.
///
/// Implementations should delegate to child schema types and add narrowly scoped violations to
/// the comparison context rather than interpreting unrelated schema keywords.
pub(super) trait CompareNarrowing<Rhs = Self> {
fn compare(&self, current: &Rhs, cx: &mut CompareCx<'_>, path: &SchemaPath) -> Result<()>;
}
pub(super) struct CompareCx<'a> {
pub(super) base: &'a ApiSchema,
pub(super) current: &'a ApiSchema,
pub(super) method: &'a str,
active: HashSet<(SchemaId, SchemaId)>,
pub(super) violations: Vec<Violation>,
}
impl<'a> CompareCx<'a> {
fn new(base: &'a ApiSchema, current: &'a ApiSchema, method: &'a str) -> Self {
Self {
base,
current,
method,
active: HashSet::new(),
violations: Vec::new(),
}
}
pub(super) fn location(&self, path: &SchemaPath) -> Location {
Location::method(self.method, path.clone())
}
pub(super) fn constraint_changed(
&mut self,
path: &SchemaPath,
before: serde_json::Value,
after: serde_json::Value,
) {
self.violations.push(Violation::ConstraintChanged {
at: self.location(path),
before: SchemaSnapshot(before),
after: SchemaSnapshot(after),
});
}
}
impl CompareNarrowing for Method {
fn compare(&self, current: &Self, cx: &mut CompareCx<'_>, path: &SchemaPath) -> Result<()> {
self.request.compare(&current.request, cx, path)?;
self.arguments
.compare(&current.arguments, cx, &path.property("params"))
}
}
impl CompareNarrowing for Arguments {
fn compare(&self, current: &Self, cx: &mut CompareCx<'_>, path: &SchemaPath) -> Result<()> {
match (self.argument(), current.argument()) {
(None, None) => {}
(Some(_), None) => cx.violations.push(Violation::PropertyRemoved {
at: cx.location(path),
}),
(None, Some(current)) if current.required => {
cx.violations.push(Violation::RequiredPropertyAdded {
at: cx.location(path),
});
}
(None, Some(_)) => {}
(Some(base), Some(current)) => {
if !base.required && current.required {
cx.violations.push(Violation::RequiredPropertyAdded {
at: cx.location(path),
});
}
base.schema.compare(&current.schema, cx, path)?;
}
}
Ok(())
}
}
impl CompareNarrowing for SchemaId {
fn compare(&self, current: &Self, cx: &mut CompareCx<'_>, path: &SchemaPath) -> Result<()> {
let (base_id, base) = cx.base.resolve(*self)?;
let (current_id, current) = cx.current.resolve(*current)?;
if !cx.active.insert((base_id, current_id)) {
return Ok(());
}
let result = match (base, current) {
(SchemaNode::Never, _) | (_, SchemaNode::Any) => Ok(()),
(SchemaNode::Any, _) | (_, SchemaNode::Never) => {
cx.constraint_changed(
path,
cx.base.snapshot(base_id)?,
cx.current.snapshot(current_id)?,
);
Ok(())
}
(SchemaNode::Rules(base), SchemaNode::Rules(current)) => {
compare_rules(base, current, cx, path)
}
(SchemaNode::Reference(_), _) | (_, SchemaNode::Reference(_)) => {
unreachable!("resolve returns concrete schema nodes")
}
};
cx.active.remove(&(base_id, current_id));
result
}
}
fn compare_rules(
base: &SchemaRules,
current: &SchemaRules,
cx: &mut CompareCx<'_>,
path: &SchemaPath,
) -> Result<()> {
value::compare_values(base.values.as_ref(), current.values.as_ref(), cx, path);
value::compare_types(base.types.as_ref(), current.types.as_ref(), cx, path);
value::compare_constraints(&base.constraints, &current.constraints, cx, path);
Ok(())
}
fn collapse_shared_envelope_changes(
violations: Vec<Violation>,
retained_methods: usize,
) -> Vec<Violation> {
let mut groups = BTreeMap::<String, Vec<Violation>>::new();
for violation in violations {
let mut breakage = violation.breakage();
breakage.method.clear();
groups
.entry(serde_json::to_string(&breakage).unwrap_or_default())
.or_default()
.push(violation);
}
let mut collapsed = Vec::new();
for mut group in groups.into_values() {
let shared = retained_methods > 0
&& group.len() == retained_methods
&& group
.first()
.and_then(Violation::location)
.is_some_and(|location| !location.path.starts_with_params());
if shared {
let mut violation = group.remove(0);
violation.set_shared_envelope();
collapsed.push(violation);
} else {
collapsed.extend(group);
}
}
sort_and_dedup(&mut collapsed);
collapsed
}
fn sort_and_dedup(violations: &mut Vec<Violation>) {
violations
.sort_by_key(|violation| serde_json::to_string(&violation.breakage()).unwrap_or_default());
violations.dedup();
}
#[cfg(test)]
#[path = "compare_tests.rs"]
mod tests;
@@ -0,0 +1,126 @@
use super::CompareCx;
use crate::ConstraintSet;
use crate::SchemaPath;
use crate::TypeSet;
use crate::ValueSet;
use crate::Violation;
use serde_json::Number;
use std::cmp::Ordering;
pub(super) fn compare_values(
base: Option<&ValueSet>,
current: Option<&ValueSet>,
cx: &mut CompareCx<'_>,
path: &SchemaPath,
) {
let Some(current) = current else {
return;
};
if base.is_none_or(|base| {
base.values
.iter()
.any(|value| !current.values.contains(value))
}) {
cx.violations.push(Violation::EnumNarrowed {
at: cx.location(path),
before: base.cloned(),
after: current.clone(),
});
}
}
pub(super) fn compare_types(
base: Option<&TypeSet>,
current: Option<&TypeSet>,
cx: &mut CompareCx<'_>,
path: &SchemaPath,
) {
let Some(current) = current else {
return;
};
if base.is_none_or(|base| !base.accepted_types().is_subset(&current.accepted_types())) {
cx.violations.push(Violation::TypeNarrowed {
at: cx.location(path),
before: base.cloned(),
after: current.clone(),
});
}
}
pub(super) fn compare_constraints(
base: &ConstraintSet,
current: &ConstraintSet,
cx: &mut CompareCx<'_>,
path: &SchemaPath,
) {
let mut before = ConstraintSet::default();
let mut after = ConstraintSet::default();
for (kind, current_value) in &current.lower_bounds {
let base_value = base.lower_bounds.get(kind);
if base_value.is_none_or(|base_value| {
compare_numbers(current_value, base_value) == Ordering::Greater
}) {
if let Some(base_value) = base_value {
before.lower_bounds.insert(*kind, base_value.clone());
}
after.lower_bounds.insert(*kind, current_value.clone());
}
}
for (kind, current_value) in &current.upper_bounds {
let base_value = base.upper_bounds.get(kind);
if base_value
.is_none_or(|base_value| compare_numbers(current_value, base_value) == Ordering::Less)
{
if let Some(base_value) = base_value {
before.upper_bounds.insert(*kind, base_value.clone());
}
after.upper_bounds.insert(*kind, current_value.clone());
}
}
if current.unique_items == Some(true) && base.unique_items != Some(true) {
before.unique_items = base.unique_items;
after.unique_items = current.unique_items;
}
for (key, current_value) in &current.opaque {
let base_value = base.opaque.get(key);
if base_value != Some(current_value) {
if let Some(base_value) = base_value {
before.opaque.insert(key.clone(), base_value.clone());
}
after.opaque.insert(key.clone(), current_value.clone());
}
}
if after != ConstraintSet::default() {
cx.constraint_changed(path, before.to_json(), after.to_json());
}
}
fn compare_numbers(left: &Number, right: &Number) -> Ordering {
if let (Some(left), Some(right)) = (left.as_i64(), right.as_i64()) {
return left.cmp(&right);
}
if let (Some(left), Some(right)) = (left.as_u64(), right.as_u64()) {
return left.cmp(&right);
}
if let (Some(left), Some(right)) = (left.as_i64(), right.as_u64()) {
return if left.is_negative() {
Ordering::Less
} else {
(left as u64).cmp(&right)
};
}
if let (Some(left), Some(right)) = (left.as_u64(), right.as_i64()) {
return if right.is_negative() {
Ordering::Greater
} else {
left.cmp(&(right as u64))
};
}
left.as_f64()
.partial_cmp(&right.as_f64())
.unwrap_or(Ordering::Equal)
}
#[cfg(test)]
#[path = "value_tests.rs"]
mod tests;
@@ -0,0 +1,110 @@
use crate::ViolationKind;
use crate::test_support::breakage;
use crate::test_support::compare;
use crate::test_support::request_schema;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn detects_enum_type_and_bound_narrowing() -> anyhow::Result<()> {
assert_eq!(
compare(
&request_schema(json!({ "enum": ["a", "b"], "type": "string" })),
&request_schema(json!({ "enum": ["a"], "type": "string" })),
)?,
vec![breakage(
ViolationKind::EnumNarrowed,
"params",
json!(["a", "b"]),
json!(["a"]),
)]
);
assert_eq!(
compare(
&request_schema(json!({ "type": "number" })),
&request_schema(json!({ "type": "integer" })),
)?,
vec![breakage(
ViolationKind::TypeNarrowed,
"params",
json!("number"),
json!("integer"),
)]
);
assert_eq!(
compare(
&request_schema(json!({ "type": ["string", "null"] })),
&request_schema(json!({ "type": "string" })),
)?,
vec![breakage(
ViolationKind::TypeNarrowed,
"params",
json!(["null", "string"]),
json!("string"),
)]
);
assert_eq!(
compare(
&request_schema(json!({ "minLength": 1, "type": "string" })),
&request_schema(json!({ "minLength": 2, "type": "string" })),
)?,
vec![breakage(
ViolationKind::ConstraintChanged,
"params",
json!({ "minLength": 1 }),
json!({ "minLength": 2 }),
)]
);
Ok(())
}
#[test]
fn allows_enum_type_and_bound_widening() -> anyhow::Result<()> {
let base = request_schema(json!({ "enum": ["a"], "minLength": 2, "type": "string" }));
let current =
request_schema(json!({ "enum": ["a", "b"], "minLength": 1, "type": ["string", "null"] }));
assert_eq!(compare(&base, &current)?, vec![]);
Ok(())
}
#[test]
fn compares_large_integer_bounds_without_losing_precision() -> anyhow::Result<()> {
let base = request_schema(json!({
"minimum": 9_007_199_254_740_992_u64,
"type": "integer"
}));
let current = request_schema(json!({
"minimum": 9_007_199_254_740_993_u64,
"type": "integer"
}));
assert_eq!(
compare(&base, &current)?,
vec![breakage(
ViolationKind::ConstraintChanged,
"params",
json!({ "minimum": 9_007_199_254_740_992_u64 }),
json!({ "minimum": 9_007_199_254_740_993_u64 }),
)]
);
Ok(())
}
#[test]
fn intersects_enum_and_const_constraints() -> anyhow::Result<()> {
let base = request_schema(json!({ "enum": ["a", "b"], "type": "string" }));
let current = request_schema(json!({
"const": "a",
"enum": ["a", "b"],
"type": "string"
}));
assert_eq!(
compare(&base, &current)?,
vec![breakage(
ViolationKind::EnumNarrowed,
"params",
json!(["a", "b"]),
json!(["a"]),
)]
);
Ok(())
}
@@ -0,0 +1,60 @@
use super::*;
use crate::ViolationKind;
use crate::test_support::breakage;
use crate::test_support::compare;
use crate::test_support::request_schema;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn detects_method_removal() -> Result<()> {
assert_eq!(
compare(
&request_schema(json!({ "type": "null" })),
&json!({ "oneOf": [] }),
)?,
vec![crate::SchemaBreakage {
kind: ViolationKind::MethodRemoved,
method: "test/method".to_string(),
path: "request".to_string(),
before: json!(true),
after: json!(false),
}]
);
Ok(())
}
#[test]
fn compares_typed_arguments_with_the_full_request_schema() -> Result<()> {
let mut base = request_schema(json!({ "type": "null" }));
base["oneOf"][0]["required"] = json!(["id", "method"]);
let current = request_schema(json!({ "type": "null" }));
assert_eq!(
compare(&base, &current)?,
vec![breakage(
ViolationKind::RequiredPropertyAdded,
"params",
json!(false),
json!(true),
)]
);
Ok(())
}
#[test]
fn retains_request_level_constraints_in_the_typed_envelope() -> Result<()> {
let base = request_schema(json!({ "type": "null" }));
let mut current = request_schema(json!({ "type": "null" }));
current["oneOf"][0]["minProperties"] = json!(3);
let mut expected = breakage(
ViolationKind::ConstraintChanged,
"request",
json!({}),
json!({ "minProperties": 3 }),
);
expected.method = "*".to_string();
assert_eq!(compare(&base, &current)?, vec![expected]);
Ok(())
}
+23
View File
@@ -1,12 +1,18 @@
mod compare;
mod model;
mod parse;
mod violation;
#[cfg(test)]
mod test_support;
use compare::compare_api_schemas;
pub use model::ApiSchema;
pub(crate) use model::Arguments;
pub(crate) use model::ArraySchema;
pub(crate) use model::ConstraintSet;
pub(crate) use model::JsonType;
pub(crate) use model::Method;
pub(crate) use model::ObjectSchema;
pub(crate) use model::SchemaId;
pub(crate) use model::SchemaNode;
@@ -15,5 +21,22 @@ pub(crate) use model::TypeSet;
pub(crate) use model::UnionKind;
pub(crate) use model::UnionSchema;
pub(crate) use model::ValueSet;
pub(crate) use violation::Location;
pub use violation::SchemaBreakage;
pub(crate) use violation::SchemaPath;
pub(crate) use violation::SchemaSnapshot;
pub(crate) use violation::Violation;
pub use violation::ViolationKind;
use anyhow::Result;
/// Finds values that the current request schema no longer accepts.
pub fn find_request_narrowing(
base: &ApiSchema,
current: &ApiSchema,
) -> Result<Vec<SchemaBreakage>> {
Ok(compare_api_schemas(base, current)?
.iter()
.map(Violation::breakage)
.collect())
}
+12
View File
@@ -8,6 +8,7 @@ mod value;
pub(crate) use array::ArraySchema;
pub(crate) use constraints::ConstraintSet;
pub(crate) use constraints::annotation;
pub(crate) use constraints::normalized;
#[cfg(test)]
pub(crate) use method::Argument;
pub(crate) use method::Arguments;
@@ -53,6 +54,7 @@ pub struct SchemaRules {
pub any_of: Option<UnionSchema>,
pub one_of: Option<UnionSchema>,
pub constraints: ConstraintSet,
pub(crate) source: Value,
}
impl ApiSchema {
@@ -118,6 +120,16 @@ impl ApiSchema {
}
}
}
pub(crate) fn snapshot(&self, id: SchemaId) -> Result<Value> {
let (_, node) = self.resolve(id)?;
Ok(match node {
SchemaNode::Any => Value::Bool(true),
SchemaNode::Never => Value::Bool(false),
SchemaNode::Reference(_) => unreachable!("resolve returns a concrete node"),
SchemaNode::Rules(rules) => rules.source.clone(),
})
}
}
fn pointer_escape(value: &str) -> String {
@@ -97,6 +97,21 @@ impl ConstraintSet {
Ok(constraints)
}
pub(crate) fn to_json(&self) -> Value {
let mut values = serde_json::Map::new();
for (kind, value) in &self.lower_bounds {
values.insert(kind.as_str().to_string(), Value::Number(value.clone()));
}
for (kind, value) in &self.upper_bounds {
values.insert(kind.as_str().to_string(), Value::Number(value.clone()));
}
if let Some(value) = self.unique_items {
values.insert("uniqueItems".to_string(), Value::Bool(value));
}
values.extend(self.opaque.clone());
Value::Object(values)
}
fn insert_lower(&mut self, kind: LowerBound, value: &Value) -> Result<()> {
self.lower_bounds.insert(
kind,
@@ -91,6 +91,13 @@ impl Method {
}
impl Arguments {
pub(crate) fn argument(&self) -> Option<Argument> {
match self {
Self::None => None,
Self::Map(argument) | Self::List(argument) | Self::Value(argument) => Some(*argument),
}
}
fn classify(parser: &Parser<'_>, argument: Argument) -> Result<Self> {
let (_, node) = parser.schema.resolve(argument.schema)?;
let SchemaNode::Rules(rules) = node else {
@@ -29,6 +29,18 @@ impl JsonType {
_ => Err(anyhow!("unsupported JSON Schema type {value}")),
}
}
fn as_str(self) -> &'static str {
match self {
Self::Array => "array",
Self::Boolean => "boolean",
Self::Integer => "integer",
Self::Null => "null",
Self::Number => "number",
Self::Object => "object",
Self::String => "string",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -64,6 +76,18 @@ impl TypeSet {
}
accepted
}
pub fn to_json(&self) -> Value {
let values = self
.declared
.iter()
.map(|value| Value::String(value.as_str().to_string()))
.collect::<Vec<_>>();
match values.as_slice() {
[value] => value.clone(),
_ => Value::Array(values),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -96,4 +120,8 @@ impl ValueSet {
values.dedup();
Ok(Some(Self { values }))
}
pub fn to_json(&self) -> Value {
Value::Array(self.values.clone())
}
}
+2
View File
@@ -10,6 +10,7 @@ use crate::UnionKind;
use crate::UnionSchema;
use crate::ValueSet;
use crate::model::annotation;
use crate::model::normalized;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
@@ -67,6 +68,7 @@ impl<'a> Parser<'a> {
any_of,
one_of,
constraints,
source: normalized(value),
})))
}
@@ -0,0 +1,45 @@
use crate::ApiSchema;
use crate::SchemaBreakage;
use crate::find_request_narrowing;
use anyhow::Result;
use serde_json::Value;
use serde_json::json;
pub(crate) fn request_schema(params: Value) -> Value {
request_schema_for("test/method", params)
}
pub(crate) fn request_schema_for(method: &str, params: Value) -> Value {
json!({
"oneOf": [{
"properties": {
"id": { "type": ["string", "integer"] },
"method": { "enum": [method], "type": "string" },
"params": params
},
"required": ["id", "method", "params"],
"type": "object"
}]
})
}
pub(crate) fn compare(base: &Value, current: &Value) -> Result<Vec<SchemaBreakage>> {
let base = ApiSchema::parse(base)?;
let current = ApiSchema::parse(current)?;
find_request_narrowing(&base, &current)
}
pub(crate) fn breakage(
kind: crate::ViolationKind,
path: &str,
before: Value,
after: Value,
) -> SchemaBreakage {
SchemaBreakage {
kind,
method: "test/method".to_string(),
path: path.to_string(),
before,
after,
}
}
+175
View File
@@ -1,6 +1,9 @@
use crate::TypeSet;
use crate::ValueSet;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::fmt;
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -15,6 +18,57 @@ pub enum ViolationKind {
UnionVariantRemoved,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Violation {
MethodRemoved {
method: String,
},
PropertyRemoved {
at: Location,
},
RequiredPropertyAdded {
at: Location,
},
TypeNarrowed {
at: Location,
before: Option<TypeSet>,
after: TypeSet,
},
EnumNarrowed {
at: Location,
before: Option<ValueSet>,
after: ValueSet,
},
ConstraintChanged {
at: Location,
before: SchemaSnapshot,
after: SchemaSnapshot,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Location {
pub scope: ViolationScope,
pub path: SchemaPath,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ViolationScope {
Method(String),
SharedEnvelope,
}
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
pub struct SchemaPath(Vec<PathSegment>);
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum PathSegment {
Property(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SchemaSnapshot(pub Value);
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SchemaBreakage {
@@ -24,3 +78,124 @@ pub struct SchemaBreakage {
pub before: Value,
pub after: Value,
}
impl Location {
pub(crate) fn method(method: &str, path: SchemaPath) -> Self {
Self {
scope: ViolationScope::Method(method.to_string()),
path,
}
}
}
impl SchemaPath {
pub(crate) fn property(&self, name: impl Into<String>) -> Self {
let mut path = self.clone();
path.0.push(PathSegment::Property(name.into()));
path
}
pub(crate) fn starts_with_params(&self) -> bool {
matches!(self.0.first(), Some(PathSegment::Property(name)) if name == "params")
}
}
impl fmt::Display for SchemaPath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, segment) in self.0.iter().enumerate() {
match segment {
PathSegment::Property(name) if index == 0 => formatter.write_str(name)?,
PathSegment::Property(name) => write!(formatter, ".{name}")?,
}
}
Ok(())
}
}
impl Violation {
pub fn breakage(&self) -> SchemaBreakage {
match self {
Self::MethodRemoved { method } => SchemaBreakage {
kind: ViolationKind::MethodRemoved,
method: method.clone(),
path: "request".to_string(),
before: Value::Bool(true),
after: Value::Bool(false),
},
Self::PropertyRemoved { at } => at_location(
ViolationKind::PropertyRemoved,
at,
Value::Bool(true),
Value::Bool(false),
),
Self::RequiredPropertyAdded { at } => at_location(
ViolationKind::RequiredPropertyAdded,
at,
Value::Bool(false),
Value::Bool(true),
),
Self::TypeNarrowed { at, before, after } => at_location(
ViolationKind::TypeNarrowed,
at,
before.as_ref().map_or(Value::Null, TypeSet::to_json),
after.to_json(),
),
Self::EnumNarrowed { at, before, after } => at_location(
ViolationKind::EnumNarrowed,
at,
before.as_ref().map_or(Value::Null, ValueSet::to_json),
after.to_json(),
),
Self::ConstraintChanged { at, before, after } => at_location(
ViolationKind::ConstraintChanged,
at,
before.0.clone(),
after.0.clone(),
),
}
}
pub(crate) fn location(&self) -> Option<&Location> {
match self {
Self::MethodRemoved { .. } => None,
Self::PropertyRemoved { at }
| Self::RequiredPropertyAdded { at }
| Self::TypeNarrowed { at, .. }
| Self::EnumNarrowed { at, .. }
| Self::ConstraintChanged { at, .. } => Some(at),
}
}
pub(crate) fn set_shared_envelope(&mut self) {
if let Some(location) = self.location_mut() {
location.scope = ViolationScope::SharedEnvelope;
}
}
fn location_mut(&mut self) -> Option<&mut Location> {
match self {
Self::MethodRemoved { .. } => None,
Self::PropertyRemoved { at }
| Self::RequiredPropertyAdded { at }
| Self::TypeNarrowed { at, .. }
| Self::EnumNarrowed { at, .. }
| Self::ConstraintChanged { at, .. } => Some(at),
}
}
}
fn at_location(kind: ViolationKind, at: &Location, before: Value, after: Value) -> SchemaBreakage {
SchemaBreakage {
kind,
method: match &at.scope {
ViolationScope::Method(method) => method.clone(),
ViolationScope::SharedEnvelope => "*".to_string(),
},
path: match at.path.to_string() {
path if path.is_empty() => "request".to_string(),
path => path,
},
before,
after,
}
}