Skip to main content

httpgenerator_openapi/
loader.rs

1use crate::{
2    OpenApiContentFormat, OpenApiDocumentLoadError, OpenApiSource, OpenApiSpecificationVersion,
3    RawOpenApiDocument, TypedOpenApiDocument, TypedOpenApiParseError, load_raw_document,
4    load_raw_document_from_source, parse_typed_document,
5};
6
7pub enum LoadedOpenApiDocument {
8    Swagger2 {
9        raw: RawOpenApiDocument,
10    },
11    OpenApi30 {
12        raw: RawOpenApiDocument,
13        document: openapiv3::OpenAPI,
14    },
15    OpenApi31 {
16        raw: RawOpenApiDocument,
17        document: openapiv3_1::OpenApi,
18    },
19    OpenApi31Raw {
20        raw: RawOpenApiDocument,
21    },
22}
23
24impl LoadedOpenApiDocument {
25    pub fn raw(&self) -> &RawOpenApiDocument {
26        match self {
27            Self::Swagger2 { raw }
28            | Self::OpenApi30 { raw, .. }
29            | Self::OpenApi31 { raw, .. }
30            | Self::OpenApi31Raw { raw } => raw,
31        }
32    }
33
34    pub fn source(&self) -> &OpenApiSource {
35        self.raw().source()
36    }
37
38    pub fn format(&self) -> OpenApiContentFormat {
39        self.raw().format()
40    }
41
42    pub fn specification_version(&self) -> OpenApiSpecificationVersion {
43        match self {
44            Self::Swagger2 { .. } => OpenApiSpecificationVersion::Swagger2,
45            Self::OpenApi30 { .. } => OpenApiSpecificationVersion::OpenApi30,
46            Self::OpenApi31 { .. } | Self::OpenApi31Raw { .. } => {
47                OpenApiSpecificationVersion::OpenApi31
48            }
49        }
50    }
51
52    pub fn as_openapi30(&self) -> Option<&openapiv3::OpenAPI> {
53        match self {
54            Self::Swagger2 { .. } | Self::OpenApi31 { .. } | Self::OpenApi31Raw { .. } => {
55                None
56            }
57            Self::OpenApi30 { document, .. } => Some(document),
58        }
59    }
60
61    pub fn as_openapi31(&self) -> Option<&openapiv3_1::OpenApi> {
62        match self {
63            Self::Swagger2 { .. } | Self::OpenApi30 { .. } | Self::OpenApi31Raw { .. } => {
64                None
65            }
66            Self::OpenApi31 { document, .. } => Some(document),
67        }
68    }
69}
70
71pub fn load_document(input: &str) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
72    load_document_with_options(input, false)
73}
74
75pub(crate) fn load_document_with_options(
76    input: &str,
77    tolerate_invalid_openapi31: bool,
78) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
79    let raw = load_raw_document(input).map_err(OpenApiDocumentLoadError::RawLoad)?;
80    load_document_from_raw_with_options(raw, tolerate_invalid_openapi31)
81}
82
83pub fn load_document_from_source(
84    source: OpenApiSource,
85) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
86    load_document_from_source_with_options(source, false)
87}
88
89pub(crate) fn load_document_from_source_with_options(
90    source: OpenApiSource,
91    tolerate_invalid_openapi31: bool,
92) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
93    let raw = load_raw_document_from_source(source).map_err(OpenApiDocumentLoadError::RawLoad)?;
94    load_document_from_raw_with_options(raw, tolerate_invalid_openapi31)
95}
96
97pub fn load_document_from_raw(
98    raw: RawOpenApiDocument,
99) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
100    load_document_from_raw_with_options(raw, false)
101}
102
103pub(crate) fn load_document_from_raw_with_options(
104    raw: RawOpenApiDocument,
105    tolerate_invalid_openapi31: bool,
106) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
107    if matches!(
108        raw.specification_version(),
109        Ok(OpenApiSpecificationVersion::Swagger2)
110    ) {
111        return Ok(LoadedOpenApiDocument::Swagger2 { raw });
112    }
113
114    match parse_typed_document(&raw) {
115        Ok(TypedOpenApiDocument::OpenApi30(document)) => {
116            Ok(LoadedOpenApiDocument::OpenApi30 { raw, document })
117        }
118        Ok(TypedOpenApiDocument::OpenApi31(document)) => {
119            Ok(LoadedOpenApiDocument::OpenApi31 { raw, document })
120        }
121        Err(TypedOpenApiParseError::Deserialize {
122            version: OpenApiSpecificationVersion::OpenApi31,
123            ..
124        }) if should_fallback_to_raw_openapi31(&raw, tolerate_invalid_openapi31) => {
125            Ok(LoadedOpenApiDocument::OpenApi31Raw { raw })
126        }
127        Err(error) => Err(OpenApiDocumentLoadError::TypedParse(error)),
128    }
129}
130
131fn should_fallback_to_raw_openapi31(
132    raw: &RawOpenApiDocument,
133    tolerate_invalid_openapi31: bool,
134) -> bool {
135    matches!(
136        raw.specification_version(),
137        Ok(OpenApiSpecificationVersion::OpenApi31)
138    ) && (is_webhook_only_openapi31_document(raw) || tolerate_invalid_openapi31)
139}
140
141fn is_webhook_only_openapi31_document(raw: &RawOpenApiDocument) -> bool {
142    matches!(
143        raw.specification_version(),
144        Ok(OpenApiSpecificationVersion::OpenApi31)
145    ) && raw.value().get("paths").is_none()
146        && raw
147            .value()
148            .get("webhooks")
149            .and_then(serde_json::Value::as_object)
150            .is_some()
151}
152
153#[cfg(test)]
154mod tests {
155    use std::{
156        fs,
157        path::{Path, PathBuf},
158        sync::atomic::{AtomicU64, Ordering},
159    };
160
161    use crate::{OpenApiSource, OpenApiSpecificationVersion, decode_raw_document};
162
163    use super::{
164        LoadedOpenApiDocument, load_document, load_document_from_raw,
165        load_document_from_raw_with_options, load_document_from_source,
166    };
167
168    static TEST_ARTIFACT_ID: AtomicU64 = AtomicU64::new(0);
169
170    #[test]
171    fn loads_openapi_thirty_documents_from_raw_input() {
172        let raw = decode_raw_document(
173            OpenApiSource::Path(PathBuf::from("openapi.json")),
174            r#"{
175                "openapi": "3.0.2",
176                "info": { "title": "Example", "version": "1.0.0" },
177                "paths": {}
178            }"#,
179        )
180        .unwrap();
181
182        let loaded = load_document_from_raw(raw).unwrap();
183
184        assert!(matches!(loaded, LoadedOpenApiDocument::OpenApi30 { .. }));
185        assert_eq!(
186            loaded.specification_version(),
187            OpenApiSpecificationVersion::OpenApi30
188        );
189        assert!(loaded.as_openapi30().is_some());
190        assert!(loaded.as_openapi31().is_none());
191    }
192
193    #[test]
194    fn loads_openapi_thirty_one_documents_from_a_source() {
195        let file = TestFile::new(
196            "openapi.yaml",
197            "openapi: 3.1.0\ninfo:\n  title: Example\n  version: 1.0.0\npaths: {}\n",
198        );
199
200        let loaded =
201            load_document_from_source(OpenApiSource::Path(file.path().to_path_buf())).unwrap();
202
203        assert!(matches!(loaded, LoadedOpenApiDocument::OpenApi31 { .. }));
204        assert_eq!(
205            loaded.specification_version(),
206            OpenApiSpecificationVersion::OpenApi31
207        );
208        assert_eq!(
209            loaded.source(),
210            &OpenApiSource::Path(file.path().to_path_buf())
211        );
212    }
213
214    #[test]
215    fn loads_webhook_only_openapi_thirty_one_documents_with_a_raw_fallback() {
216        let raw = decode_raw_document(
217            OpenApiSource::Path(PathBuf::from("test/OpenAPI/v3.1/webhook-example.json")),
218            include_str!("../../../../test/OpenAPI/v3.1/webhook-example.json"),
219        )
220        .unwrap();
221
222        let loaded = load_document_from_raw(raw).unwrap();
223
224        assert!(matches!(
225            loaded,
226            LoadedOpenApiDocument::OpenApi31Raw { .. }
227        ));
228        assert_eq!(
229            loaded.specification_version(),
230            OpenApiSpecificationVersion::OpenApi31
231        );
232        assert!(loaded.as_openapi31().is_none());
233    }
234
235    #[test]
236    fn tolerant_loader_accepts_invalid_openapi_thirty_one_documents() {
237        let raw = decode_raw_document(
238            OpenApiSource::Path(PathBuf::from("test/OpenAPI/v3.1/non-oauth-scopes.json")),
239            include_str!("../../../../test/OpenAPI/v3.1/non-oauth-scopes.json"),
240        )
241        .unwrap();
242
243        let loaded = load_document_from_raw_with_options(raw, true).unwrap();
244
245        assert!(matches!(loaded, LoadedOpenApiDocument::OpenApi31Raw { .. }));
246        assert_eq!(
247            loaded.specification_version(),
248            OpenApiSpecificationVersion::OpenApi31
249        );
250        assert!(loaded.as_openapi31().is_none());
251    }
252
253    #[test]
254    fn loads_swagger_two_documents_with_a_raw_bridge() {
255        let raw = decode_raw_document(
256            OpenApiSource::Path(PathBuf::from("swagger.json")),
257            r#"{
258                "swagger": "2.0",
259                "info": { "title": "Example", "version": "1.0.0" },
260                "paths": {}
261            }"#,
262        )
263        .unwrap();
264
265        let loaded = load_document_from_raw(raw).unwrap();
266
267        assert!(matches!(loaded, LoadedOpenApiDocument::Swagger2 { .. }));
268        assert_eq!(
269            loaded.specification_version(),
270            OpenApiSpecificationVersion::Swagger2
271        );
272        assert!(loaded.as_openapi30().is_none());
273        assert!(loaded.as_openapi31().is_none());
274    }
275
276    #[test]
277    fn load_document_reads_and_parses_local_files() {
278        let file = TestFile::new(
279            "openapi.json",
280            r#"{
281                "openapi": "3.0.2",
282                "info": { "title": "Example", "version": "1.0.0" },
283                "paths": {}
284            }"#,
285        );
286
287        let loaded = load_document(file.path().to_str().unwrap()).unwrap();
288
289        assert!(matches!(loaded, LoadedOpenApiDocument::OpenApi30 { .. }));
290        assert_eq!(loaded.format(), crate::OpenApiContentFormat::Json);
291    }
292
293    fn unique_test_path(file_name: &str) -> PathBuf {
294        let artifact_id = TEST_ARTIFACT_ID.fetch_add(1, Ordering::Relaxed);
295        let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
296            .join("target")
297            .join("test-data");
298
299        fs::create_dir_all(&directory).unwrap();
300
301        directory.join(format!(
302            "loader-{}-{}-{file_name}",
303            std::process::id(),
304            artifact_id
305        ))
306    }
307
308    struct TestFile {
309        path: PathBuf,
310    }
311
312    impl TestFile {
313        fn new(file_name: &str, content: &str) -> Self {
314            let path = unique_test_path(file_name);
315            fs::write(&path, content).unwrap();
316            Self { path }
317        }
318
319        fn path(&self) -> &Path {
320            &self.path
321        }
322    }
323
324    impl Drop for TestFile {
325        fn drop(&mut self) {
326            let _ = fs::remove_file(&self.path);
327        }
328    }
329}