use crate::{
CompatibilityError, CompatibilityResult, Role, analyze_compat, explain_compat_failure,
validate_compatibility_input,
};
use jsoncompat_openapi::{
LoweredOperation, OpenApiDocument, OpenApiLoweringError, OperationKey, lower_operations,
};
use std::collections::BTreeMap;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum OpenApiCompatibilityError {
#[error(transparent)]
Lowering(#[from] OpenApiLoweringError),
#[error(transparent)]
Compatibility(#[from] CompatibilityError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OpenApiCompatibilitySurface {
Operation,
Request,
Response,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct OpenApiCompatibilityIssue {
pub method: String,
pub path: String,
pub surface: OpenApiCompatibilitySurface,
pub message: String,
pub kind: OpenApiCompatibilityIssueKind,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum OpenApiCompatibilityIssueKind {
OperationRemoved,
Incompatible {
direction: Role,
counterexample: serde_json::Value,
},
Unknown {
reason: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
pub struct OpenApiCompatibilityReport {
issues: Vec<OpenApiCompatibilityIssue>,
}
impl OpenApiCompatibilityReport {
#[must_use]
pub fn is_compatible(&self) -> bool {
self.issues.is_empty()
}
#[must_use]
pub fn is_incompatible(&self) -> bool {
self.issues
.iter()
.any(|issue| !matches!(issue.kind, OpenApiCompatibilityIssueKind::Unknown { .. }))
}
#[must_use]
pub fn is_unknown(&self) -> bool {
!self.is_compatible() && !self.is_incompatible()
}
#[must_use]
pub fn issues(&self) -> &[OpenApiCompatibilityIssue] {
&self.issues
}
fn push(
&mut self,
operation: &OperationKey,
surface: OpenApiCompatibilitySurface,
message: impl Into<String>,
kind: OpenApiCompatibilityIssueKind,
) {
self.issues.push(OpenApiCompatibilityIssue {
method: operation.method.clone(),
path: operation.path.clone(),
surface,
message: message.into(),
kind,
});
}
}
struct OpenApiCompatibilityInput<'a> {
document: &'a OpenApiDocument,
operations: BTreeMap<OperationKey, PreparedOperation>,
}
struct PreparedOperation {
lowered: LoweredOperation,
request: crate::SchemaDocument,
response: crate::SchemaDocument,
}
struct OperationSurface<'a> {
old: &'a crate::SchemaDocument,
new: &'a crate::SchemaDocument,
changed: bool,
surface: OpenApiCompatibilitySurface,
role: Role,
fallback_message: &'static str,
}
impl<'a> OpenApiCompatibilityInput<'a> {
fn new(document: &'a OpenApiDocument) -> Result<Self, OpenApiCompatibilityError> {
let mut operations = BTreeMap::new();
for (key, lowered) in lower_operations(document)? {
let request = prepare_lowered_contract(document, &lowered.request)?;
let response = prepare_lowered_contract(document, &lowered.response)?;
operations.insert(
key,
PreparedOperation {
lowered,
request,
response,
},
);
}
Ok(Self {
document,
operations,
})
}
}
pub fn validate_openapi_compatibility_input(
document: &OpenApiDocument,
) -> Result<(), OpenApiCompatibilityError> {
OpenApiCompatibilityInput::new(document).map(|_| ())
}
pub fn check_openapi_compat(
old: &OpenApiDocument,
new: &OpenApiDocument,
) -> Result<OpenApiCompatibilityReport, OpenApiCompatibilityError> {
let old = OpenApiCompatibilityInput::new(old)?;
let new = OpenApiCompatibilityInput::new(new)?;
let mut report = OpenApiCompatibilityReport::default();
let dialects_differ = !old.document.uses_same_schema_dialect_as(new.document)?;
for (key, old_operation) in &old.operations {
let Some(new_operation) = new.operations.get(key) else {
report.push(
key,
OpenApiCompatibilitySurface::Operation,
"operation was removed",
OpenApiCompatibilityIssueKind::OperationRemoved,
);
continue;
};
for surface in [
OperationSurface {
old: &old_operation.request,
new: &new_operation.request,
changed: old_operation.lowered.request != new_operation.lowered.request
|| dialects_differ,
surface: OpenApiCompatibilitySurface::Request,
role: Role::Deserializer,
fallback_message: "request contract became incompatible",
},
OperationSurface {
old: &old_operation.response,
new: &new_operation.response,
changed: old_operation.lowered.response != new_operation.lowered.response
|| dialects_differ,
surface: OpenApiCompatibilitySurface::Response,
role: Role::Serializer,
fallback_message: "response contract became incompatible",
},
] {
surface.report_if_incompatible(&mut report, key)?;
}
}
Ok(report)
}
impl OperationSurface<'_> {
fn report_if_incompatible(
self,
report: &mut OpenApiCompatibilityReport,
operation: &OperationKey,
) -> Result<(), OpenApiCompatibilityError> {
if !self.changed {
return Ok(());
}
let (message, kind) = match analyze_compat(self.old, self.new, self.role)? {
CompatibilityResult::Compatible => return Ok(()),
CompatibilityResult::Incompatible {
direction,
counterexample,
} => (
explain_compat_failure(self.old, self.new, self.role)?
.unwrap_or_else(|| self.fallback_message.to_owned()),
OpenApiCompatibilityIssueKind::Incompatible {
direction,
counterexample,
},
),
CompatibilityResult::Unknown { reason } => (
reason.clone(),
OpenApiCompatibilityIssueKind::Unknown { reason },
),
};
report.push(operation, self.surface, message, kind);
Ok(())
}
}
fn prepare_lowered_contract(
document: &OpenApiDocument,
schema: &serde_json::Value,
) -> Result<crate::SchemaDocument, OpenApiCompatibilityError> {
let schema = document.lowered_contract_document(schema)?;
validate_compatibility_input(&schema)?;
Ok(schema)
}