1use crate::{
4 CompatibilityError, CompatibilityResult, Role, analyze_compat, explain_compat_failure,
5 validate_compatibility_input,
6};
7use jsoncompat_openapi::{
8 LoweredOperation, OpenApiDocument, OpenApiLoweringError, OperationKey, lower_operations,
9};
10use std::collections::BTreeMap;
11
12#[derive(Debug, thiserror::Error)]
13#[non_exhaustive]
14pub enum OpenApiCompatibilityError {
15 #[error(transparent)]
16 Lowering(#[from] OpenApiLoweringError),
17 #[error(transparent)]
18 Compatibility(#[from] CompatibilityError),
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum OpenApiCompatibilitySurface {
24 Operation,
25 Request,
26 Response,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
30pub struct OpenApiCompatibilityIssue {
31 pub method: String,
32 pub path: String,
33 pub surface: OpenApiCompatibilitySurface,
34 pub message: String,
35 pub kind: OpenApiCompatibilityIssueKind,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
41#[serde(tag = "status", rename_all = "snake_case")]
42pub enum OpenApiCompatibilityIssueKind {
43 OperationRemoved,
44 Incompatible {
45 direction: Role,
46 counterexample: serde_json::Value,
47 },
48 Unknown {
49 reason: String,
50 },
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
54pub struct OpenApiCompatibilityReport {
55 issues: Vec<OpenApiCompatibilityIssue>,
56}
57
58impl OpenApiCompatibilityReport {
59 #[must_use]
60 pub fn is_compatible(&self) -> bool {
61 self.issues.is_empty()
62 }
63
64 #[must_use]
66 pub fn is_incompatible(&self) -> bool {
67 self.issues
68 .iter()
69 .any(|issue| !matches!(issue.kind, OpenApiCompatibilityIssueKind::Unknown { .. }))
70 }
71 #[must_use]
72 pub fn is_unknown(&self) -> bool {
73 !self.is_compatible() && !self.is_incompatible()
74 }
75
76 #[must_use]
77 pub fn issues(&self) -> &[OpenApiCompatibilityIssue] {
78 &self.issues
79 }
80
81 fn push(
82 &mut self,
83 operation: &OperationKey,
84 surface: OpenApiCompatibilitySurface,
85 message: impl Into<String>,
86 kind: OpenApiCompatibilityIssueKind,
87 ) {
88 self.issues.push(OpenApiCompatibilityIssue {
89 method: operation.method.clone(),
90 path: operation.path.clone(),
91 surface,
92 message: message.into(),
93 kind,
94 });
95 }
96}
97
98struct OpenApiCompatibilityInput<'a> {
99 document: &'a OpenApiDocument,
100 operations: BTreeMap<OperationKey, PreparedOperation>,
101}
102
103struct PreparedOperation {
104 lowered: LoweredOperation,
105 request: crate::SchemaDocument,
106 response: crate::SchemaDocument,
107}
108
109struct OperationSurface<'a> {
110 old: &'a crate::SchemaDocument,
111 new: &'a crate::SchemaDocument,
112 changed: bool,
113 surface: OpenApiCompatibilitySurface,
114 role: Role,
115 fallback_message: &'static str,
116}
117
118impl<'a> OpenApiCompatibilityInput<'a> {
119 fn new(document: &'a OpenApiDocument) -> Result<Self, OpenApiCompatibilityError> {
120 let mut operations = BTreeMap::new();
121 for (key, lowered) in lower_operations(document)? {
122 let request = prepare_lowered_contract(document, &lowered.request)?;
123 let response = prepare_lowered_contract(document, &lowered.response)?;
124 operations.insert(
125 key,
126 PreparedOperation {
127 lowered,
128 request,
129 response,
130 },
131 );
132 }
133
134 Ok(Self {
135 document,
136 operations,
137 })
138 }
139}
140
141pub fn validate_openapi_compatibility_input(
148 document: &OpenApiDocument,
149) -> Result<(), OpenApiCompatibilityError> {
150 OpenApiCompatibilityInput::new(document).map(|_| ())
151}
152
153pub fn check_openapi_compat(
154 old: &OpenApiDocument,
155 new: &OpenApiDocument,
156) -> Result<OpenApiCompatibilityReport, OpenApiCompatibilityError> {
157 let old = OpenApiCompatibilityInput::new(old)?;
158 let new = OpenApiCompatibilityInput::new(new)?;
159 let mut report = OpenApiCompatibilityReport::default();
160 let dialects_differ = !old.document.uses_same_schema_dialect_as(new.document)?;
161
162 for (key, old_operation) in &old.operations {
163 let Some(new_operation) = new.operations.get(key) else {
164 report.push(
165 key,
166 OpenApiCompatibilitySurface::Operation,
167 "operation was removed",
168 OpenApiCompatibilityIssueKind::OperationRemoved,
169 );
170 continue;
171 };
172 for surface in [
173 OperationSurface {
174 old: &old_operation.request,
175 new: &new_operation.request,
176 changed: old_operation.lowered.request != new_operation.lowered.request
177 || dialects_differ,
178 surface: OpenApiCompatibilitySurface::Request,
179 role: Role::Deserializer,
180 fallback_message: "request contract became incompatible",
181 },
182 OperationSurface {
183 old: &old_operation.response,
184 new: &new_operation.response,
185 changed: old_operation.lowered.response != new_operation.lowered.response
186 || dialects_differ,
187 surface: OpenApiCompatibilitySurface::Response,
188 role: Role::Serializer,
189 fallback_message: "response contract became incompatible",
190 },
191 ] {
192 surface.report_if_incompatible(&mut report, key)?;
193 }
194 }
195
196 Ok(report)
197}
198
199impl OperationSurface<'_> {
200 fn report_if_incompatible(
201 self,
202 report: &mut OpenApiCompatibilityReport,
203 operation: &OperationKey,
204 ) -> Result<(), OpenApiCompatibilityError> {
205 if !self.changed {
206 return Ok(());
207 }
208 let (message, kind) = match analyze_compat(self.old, self.new, self.role)? {
209 CompatibilityResult::Compatible => return Ok(()),
210 CompatibilityResult::Incompatible {
211 direction,
212 counterexample,
213 } => (
214 explain_compat_failure(self.old, self.new, self.role)?
215 .unwrap_or_else(|| self.fallback_message.to_owned()),
216 OpenApiCompatibilityIssueKind::Incompatible {
217 direction,
218 counterexample,
219 },
220 ),
221 CompatibilityResult::Unknown { reason } => (
222 reason.clone(),
223 OpenApiCompatibilityIssueKind::Unknown { reason },
224 ),
225 };
226 report.push(operation, self.surface, message, kind);
227 Ok(())
228 }
229}
230
231fn prepare_lowered_contract(
232 document: &OpenApiDocument,
233 schema: &serde_json::Value,
234) -> Result<crate::SchemaDocument, OpenApiCompatibilityError> {
235 let schema = document.lowered_contract_document(schema)?;
236 validate_compatibility_input(&schema)?;
237 Ok(schema)
238}