Skip to main content

jsoncompat/
openapi_compat.rs

1//! Compatibility reporting over lowered OpenAPI request/response contracts.
2
3use 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/// Removed operations are structural breaks; schema breaks carry a validated
39/// value. An unsuccessful inclusion proof alone is explicitly unknown.
40#[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    /// True only when at least one break was established.
65    #[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
141/// Return whether an OpenAPI document is fully supported by the compatibility layer.
142///
143/// [`OpenApiDocument::from_json`] validates OpenAPI document shape. This helper
144/// finishes the compatibility-specific validation pass: it resolves and lowers
145/// every operation contract, then verifies that the generated request and
146/// response schemas stay inside the raw JSON Schema compatibility subset.
147pub 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}