pub mod json;
pub mod openapi;
pub mod protobuf;
use crate::diff::json::{canonical_json, sha256_hex};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Format {
Openapi,
Graphql,
Protobuf,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "format", rename_all = "snake_case")]
pub enum NativeLocation {
Openapi {
method: Option<String>,
path: Option<String>,
parameter_in: Option<String>,
status: Option<String>,
pointer: String,
resolved: bool,
},
Graphql {
coordinate: String,
},
Protobuf {
full_name: String,
number: Option<i32>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EntityKind {
Service,
Operation,
Type,
Field,
Argument,
Result,
Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Entity {
pub id: String,
pub kind: EntityKind,
pub name: String,
pub parent: Option<String>,
pub services: Vec<String>,
pub native: NativeLocation,
pub attributes: Value,
pub references: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Contract {
pub format: Format,
pub title: String,
pub version: String,
pub entities: Vec<Entity>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ChangeKind {
ServiceAdded,
ServiceRemoved,
OperationAdded,
OperationRemoved,
OperationRenamed,
TypeAdded,
TypeRemoved,
TypeKindChanged,
ArgumentAdded,
ArgumentRemoved,
ArgumentRequiredChanged,
ArgumentTypeChanged,
FieldAdded,
FieldRemoved,
FieldTypeChanged,
FieldRequiredChanged,
NullabilityChanged,
EnumValueAdded,
EnumValueRemoved,
DeprecatedChanged,
DefaultChanged,
PaginationChanged,
WireIdentityChanged,
ResultAdded,
ResultRemoved,
ConstraintChanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Severity {
Breaking,
NonBreaking,
Review,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Change {
pub id: String,
pub subject: String,
pub kind: ChangeKind,
pub severity: Severity,
pub impacts: Vec<String>,
pub native: NativeLocation,
pub native_kind: String,
pub details: Value,
}
impl Change {
pub fn refresh_id(&mut self) {
self.id = change_id(self);
}
}
pub fn entity_id(format: Format, kind: EntityKind, coordinate: &str) -> String {
format!(
"ve2_{}",
sha256_hex(
canonical_json(&json!({"v":2,"format":format,"kind":kind,"coordinate":coordinate}))
.as_bytes()
)
)
}
pub fn change_id(change: &Change) -> String {
format!(
"vc2_{}",
sha256_hex(
canonical_json(
&json!({"v":2,"subject":change.subject,"kind":change.kind,"details":change.details})
)
.as_bytes()
)
)
}
pub trait Adapter {
fn normalize_attributes(&self, subject: &Entity) -> Value {
subject.attributes.clone()
}
fn change_location(&self, subject: &Entity, _pointer: &str) -> NativeLocation {
subject.native.clone()
}
fn attribute_kind(
&self,
_subject: &Entity,
_pointer: &str,
_change: &crate::diff::ValueChangeKind,
) -> ChangeKind {
ChangeKind::ConstraintChanged
}
fn structural_kind(&self, _subject: &Entity, _field: &str) -> Option<ChangeKind> {
Some(ChangeKind::ConstraintChanged)
}
fn classify(
&self,
subject: &Entity,
kind: ChangeKind,
details: &Value,
) -> (Severity, Vec<String>);
}
pub fn compare(
old: &Contract,
new: &Contract,
adapter: &impl Adapter,
) -> anyhow::Result<Vec<Change>> {
anyhow::ensure!(
old.format == new.format,
"Cannot compare different contract formats"
);
let index = |contract: &Contract| -> anyhow::Result<std::collections::HashMap<String, usize>> {
let mut map = std::collections::HashMap::with_capacity(contract.entities.len());
for (i, entity) in contract.entities.iter().enumerate() {
anyhow::ensure!(
map.insert(entity.id.clone(), i).is_none(),
"Duplicate contract entity ID: {}",
entity.id
);
}
Ok(map)
};
let old_index = index(old)?;
let new_index = index(new)?;
let mut changes = Vec::new();
for entity in &old.entities {
if let Some(&position) = new_index.get(&entity.id) {
let next = &new.entities[position];
let mut values = Vec::new();
crate::diff::diff_values(
&adapter.normalize_attributes(entity),
&adapter.normalize_attributes(next),
&mut String::new(),
&mut values,
);
for value in values {
use crate::diff::ValueChangeKind as V;
let (before, after) = match &value.kind {
V::Added(v) => (json!({"present":false}), json!({"present":true,"value":v})),
V::Removed(v) => (json!({"present":true,"value":v}), json!({"present":false})),
V::Changed { old, new } => (
json!({"present":true,"value":old}),
json!({"present":true,"value":new}),
),
};
let kind = adapter.attribute_kind(entity, &value.pointer, &value.kind);
let details = json!({"pointer":value.pointer,"before":before,"after":after});
push(&mut changes, entity, kind, details, adapter);
}
for (field, before, after) in structural_values(entity, next) {
if before != after
&& let Some(kind) = adapter.structural_kind(entity, field)
{
push(
&mut changes,
entity,
kind,
json!({"structural":field,"before":before,"after":after}),
adapter,
);
}
}
} else {
push(
&mut changes,
entity,
removed(entity.kind),
json!({}),
adapter,
);
}
}
for entity in &new.entities {
if !old_index.contains_key(&entity.id) {
push(&mut changes, entity, added(entity.kind), json!({}), adapter);
}
}
Ok(changes)
}
fn structural_values(old: &Entity, new: &Entity) -> Vec<(&'static str, Value, Value)> {
let mut old_services = old.services.clone();
let mut new_services = new.services.clone();
old_services.sort();
new_services.sort();
let mut old_references = old.references.clone();
let mut new_references = new.references.clone();
old_references.sort();
new_references.sort();
vec![
("name", json!(old.name), json!(new.name)),
("parent", json!(old.parent), json!(new.parent)),
("services", json!(old_services), json!(new_services)),
("references", json!(old_references), json!(new_references)),
("native", json!(old.native), json!(new.native)),
]
}
fn push(
changes: &mut Vec<Change>,
entity: &Entity,
kind: ChangeKind,
details: Value,
adapter: &impl Adapter,
) {
let (severity, impacts) = adapter.classify(entity, kind, &details);
let native = adapter.change_location(
entity,
details.get("pointer").and_then(Value::as_str).unwrap_or(""),
);
let mut change = Change {
id: String::new(),
subject: entity.id.clone(),
kind,
severity,
impacts,
native,
native_kind: "surface_changed".into(),
details,
};
change.refresh_id();
changes.push(change);
}
fn added(kind: EntityKind) -> ChangeKind {
match kind {
EntityKind::Service => ChangeKind::ServiceAdded,
EntityKind::Operation => ChangeKind::OperationAdded,
EntityKind::Type => ChangeKind::TypeAdded,
EntityKind::Field => ChangeKind::FieldAdded,
EntityKind::Argument => ChangeKind::ArgumentAdded,
EntityKind::Result => ChangeKind::ResultAdded,
EntityKind::Value => ChangeKind::EnumValueAdded,
}
}
fn removed(kind: EntityKind) -> ChangeKind {
match kind {
EntityKind::Service => ChangeKind::ServiceRemoved,
EntityKind::Operation => ChangeKind::OperationRemoved,
EntityKind::Type => ChangeKind::TypeRemoved,
EntityKind::Field => ChangeKind::FieldRemoved,
EntityKind::Argument => ChangeKind::ArgumentRemoved,
EntityKind::Result => ChangeKind::ResultRemoved,
EntityKind::Value => ChangeKind::EnumValueRemoved,
}
}
pub(super) fn semantic_attributes(value: &Value) -> Value {
const SCHEMA_MAPS: &[&str] = &["properties", "patternProperties", "definitions", "$defs"];
const SCHEMA_CHILDREN: &[&str] = &[
"schema",
"items",
"not",
"additionalProperties",
"if",
"then",
"else",
];
const SCHEMA_ARRAYS: &[&str] = &["allOf", "oneOf", "anyOf"];
let Some(map) = value.as_object() else {
return value.clone();
};
let mut output = serde_json::Map::new();
for (key, child) in map {
if ["description", "title", "example", "examples"].contains(&key.as_str())
|| key.starts_with("x-")
{
continue;
}
let normalized = if key == "required" || key == "enum" {
if let Some(items) = child.as_array() {
let mut items = items.clone();
items.sort_by_key(crate::diff::json::canonical_json);
Value::Array(items)
} else {
child.clone()
}
} else if SCHEMA_MAPS.contains(&key.as_str()) {
child
.as_object()
.map(|members| {
Value::Object(
members
.iter()
.map(|(name, schema)| (name.clone(), semantic_attributes(schema)))
.collect(),
)
})
.unwrap_or_else(|| child.clone())
} else if SCHEMA_CHILDREN.contains(&key.as_str()) {
semantic_attributes(child)
} else if SCHEMA_ARRAYS.contains(&key.as_str()) {
child
.as_array()
.map(|items| Value::Array(items.iter().map(semantic_attributes).collect()))
.unwrap_or_else(|| child.clone())
} else {
child.clone()
};
output.insert(key.clone(), normalized);
}
Value::Object(output)
}