Skip to main content

httpgenerator_openapi/
raw.rs

1use std::fs;
2
3use serde_json::Value;
4
5use crate::{
6    OpenApiContentFormat, OpenApiSource, OpenApiSpecificationVersion, RawOpenApiLoadError,
7    SpecificationVersionDetectionError, classify_source, detect_content_format,
8    detect_specification_version,
9};
10
11#[derive(Debug, Clone, PartialEq)]
12pub struct RawOpenApiDocument {
13    source: OpenApiSource,
14    format: OpenApiContentFormat,
15    content: String,
16    value: Value,
17}
18
19impl RawOpenApiDocument {
20    pub fn source(&self) -> &OpenApiSource {
21        &self.source
22    }
23
24    pub fn format(&self) -> OpenApiContentFormat {
25        self.format
26    }
27
28    pub fn content(&self) -> &str {
29        &self.content
30    }
31
32    pub fn value(&self) -> &Value {
33        &self.value
34    }
35
36    pub fn into_value(self) -> Value {
37        self.value
38    }
39
40    pub fn specification_version(
41        &self,
42    ) -> Result<OpenApiSpecificationVersion, SpecificationVersionDetectionError> {
43        detect_specification_version(&self.value)
44    }
45}
46
47pub fn load_raw_document(input: &str) -> Result<RawOpenApiDocument, RawOpenApiLoadError> {
48    let source = classify_source(input).map_err(RawOpenApiLoadError::SourceClassification)?;
49    load_raw_document_from_source(source)
50}
51
52pub fn load_raw_document_from_source(
53    source: OpenApiSource,
54) -> Result<RawOpenApiDocument, RawOpenApiLoadError> {
55    let content = load_source_content(&source)?;
56    decode_raw_document(source, content)
57}
58
59pub fn decode_raw_document(
60    source: OpenApiSource,
61    content: impl Into<String>,
62) -> Result<RawOpenApiDocument, RawOpenApiLoadError> {
63    let content = content.into();
64    let format = detect_content_format(Some(&source), &content).map_err(|error| {
65        RawOpenApiLoadError::FormatDetection {
66            source: source.clone(),
67            error,
68        }
69    })?;
70    let value = decode_content(&source, format, &content)?;
71
72    Ok(RawOpenApiDocument {
73        source,
74        format,
75        content,
76        value,
77    })
78}
79
80fn load_source_content(source: &OpenApiSource) -> Result<String, RawOpenApiLoadError> {
81    match source {
82        OpenApiSource::Path(path) => {
83            fs::read_to_string(path).map_err(|error| RawOpenApiLoadError::FileRead {
84                path: path.clone(),
85                reason: error.to_string(),
86            })
87        }
88        OpenApiSource::Url(url) => {
89            let response = reqwest::blocking::get(url.clone()).map_err(|error| {
90                RawOpenApiLoadError::HttpRequest {
91                    url: url.clone(),
92                    reason: error.to_string(),
93                }
94            })?;
95
96            let status = response.status();
97            if !status.is_success() {
98                return Err(RawOpenApiLoadError::HttpStatus {
99                    url: url.clone(),
100                    status,
101                });
102            }
103
104            let bytes = response
105                .bytes()
106                .map_err(|error| RawOpenApiLoadError::HttpBodyRead {
107                    url: url.clone(),
108                    reason: error.to_string(),
109                })?;
110
111            String::from_utf8(bytes.to_vec()).map_err(|error| RawOpenApiLoadError::HttpBodyRead {
112                url: url.clone(),
113                reason: error.to_string(),
114            })
115        }
116    }
117}
118
119fn decode_content(
120    source: &OpenApiSource,
121    format: OpenApiContentFormat,
122    content: &str,
123) -> Result<Value, RawOpenApiLoadError> {
124    let decode_input = content.strip_prefix('\u{feff}').unwrap_or(content);
125
126    match format {
127        OpenApiContentFormat::Json => {
128            serde_json::from_str(decode_input).map_err(|error| RawOpenApiLoadError::Decode {
129                source: source.clone(),
130                format,
131                reason: error.to_string(),
132            })
133        }
134        OpenApiContentFormat::Yaml => {
135            yaml_serde::from_str(decode_input).map_err(|error| RawOpenApiLoadError::Decode {
136                source: source.clone(),
137                format,
138                reason: error.to_string(),
139            })
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use std::{
147        fs,
148        io::{Read, Write},
149        net::{Shutdown, TcpListener},
150        path::{Path, PathBuf},
151        sync::atomic::{AtomicU64, Ordering},
152        thread,
153    };
154
155    use serde_json::json;
156    use url::Url;
157
158    use super::load_raw_document;
159    use crate::{
160        ContentFormatDetectionError, OpenApiContentFormat, OpenApiSource, RawOpenApiLoadError,
161    };
162
163    static TEST_ARTIFACT_ID: AtomicU64 = AtomicU64::new(0);
164
165    #[test]
166    fn loads_local_json_documents() {
167        let file = TestFile::new(
168            "openapi.json",
169            "\u{feff}{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Example\"}}",
170        );
171        let document = load_raw_document(file.path().to_str().unwrap()).unwrap();
172
173        assert_eq!(
174            document.source(),
175            &OpenApiSource::Path(file.path().to_path_buf())
176        );
177        assert_eq!(document.format(), OpenApiContentFormat::Json);
178        assert_eq!(
179            document.value(),
180            &json!({
181                "openapi": "3.1.0",
182                "info": { "title": "Example" }
183            })
184        );
185    }
186
187    #[test]
188    fn loads_local_yaml_documents_by_sniffing_content() {
189        let file = TestFile::new("openapi", "openapi: 3.0.0\ninfo:\n  title: Example\n");
190        let document = load_raw_document(file.path().to_str().unwrap()).unwrap();
191
192        assert_eq!(
193            document.source(),
194            &OpenApiSource::Path(file.path().to_path_buf())
195        );
196        assert_eq!(document.format(), OpenApiContentFormat::Yaml);
197        assert_eq!(
198            document.value(),
199            &json!({
200                "openapi": "3.0.0",
201                "info": { "title": "Example" }
202            })
203        );
204    }
205
206    #[test]
207    fn loads_http_json_documents() {
208        let server = TestHttpServer::respond_once(
209            "/openapi.json",
210            "200 OK",
211            &[("Content-Type", "application/json")],
212            "{\"openapi\":\"3.0.0\",\"info\":{\"title\":\"Remote\"}}",
213        );
214        let url = server.url().clone();
215        let document = load_raw_document(url.as_str()).unwrap();
216
217        assert_eq!(document.source(), &OpenApiSource::Url(url));
218        assert_eq!(document.format(), OpenApiContentFormat::Json);
219        assert_eq!(document.value()["info"]["title"], "Remote");
220    }
221
222    #[test]
223    fn returns_file_read_errors_for_missing_files() {
224        let path = unique_test_path("missing-openapi.json");
225        let error = load_raw_document(path.to_str().unwrap()).unwrap_err();
226
227        assert!(matches!(
228            error,
229            RawOpenApiLoadError::FileRead { path: actual, reason }
230                if actual == path && reason.contains("file")
231        ));
232    }
233
234    #[test]
235    fn returns_http_status_errors_for_unsuccessful_responses() {
236        let server = TestHttpServer::respond_once(
237            "/missing.json",
238            "404 Not Found",
239            &[("Content-Type", "text/plain")],
240            "missing",
241        );
242        let url = server.url().clone();
243        let error = load_raw_document(url.as_str()).unwrap_err();
244
245        assert!(matches!(
246            error,
247            RawOpenApiLoadError::HttpStatus { url: actual, status } if actual == url && status.as_u16() == 404
248        ));
249    }
250
251    #[test]
252    fn returns_format_detection_errors_for_unknown_content() {
253        let file = TestFile::new("openapi", "not a recognized document");
254        let source = OpenApiSource::Path(file.path().to_path_buf());
255        let error = load_raw_document(file.path().to_str().unwrap()).unwrap_err();
256
257        assert_eq!(
258            error,
259            RawOpenApiLoadError::FormatDetection {
260                source,
261                error: ContentFormatDetectionError::UnknownFormat,
262            }
263        );
264    }
265
266    #[test]
267    fn returns_decode_errors_for_invalid_json_content() {
268        let file = TestFile::new("openapi.json", "{\"openapi\":");
269        let source = OpenApiSource::Path(file.path().to_path_buf());
270        let error = load_raw_document(file.path().to_str().unwrap()).unwrap_err();
271
272        assert!(matches!(
273            error,
274            RawOpenApiLoadError::Decode { source: actual, format, .. }
275                if actual == source && format == OpenApiContentFormat::Json
276        ));
277    }
278
279    fn unique_test_path(file_name: &str) -> PathBuf {
280        let artifact_id = TEST_ARTIFACT_ID.fetch_add(1, Ordering::Relaxed);
281        let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
282            .join("target")
283            .join("test-data");
284
285        fs::create_dir_all(&directory).unwrap();
286
287        directory.join(format!(
288            "raw-{}-{}-{file_name}",
289            std::process::id(),
290            artifact_id
291        ))
292    }
293
294    struct TestFile {
295        path: PathBuf,
296    }
297
298    impl TestFile {
299        fn new(file_name: &str, content: &str) -> Self {
300            let path = unique_test_path(file_name);
301            fs::write(&path, content).unwrap();
302            Self { path }
303        }
304
305        fn path(&self) -> &Path {
306            &self.path
307        }
308    }
309
310    impl Drop for TestFile {
311        fn drop(&mut self) {
312            let _ = fs::remove_file(&self.path);
313        }
314    }
315
316    struct TestHttpServer {
317        url: Url,
318        handle: Option<thread::JoinHandle<()>>,
319    }
320
321    impl TestHttpServer {
322        fn respond_once(
323            path: &str,
324            status_line: &str,
325            headers: &[(&str, &str)],
326            body: &str,
327        ) -> Self {
328            let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
329            let address = listener.local_addr().unwrap();
330            let body = body.to_string();
331            let status_line = status_line.to_string();
332            let header_block = headers
333                .iter()
334                .map(|(name, value)| format!("{name}: {value}\r\n"))
335                .collect::<String>();
336            let handle = thread::spawn(move || {
337                let (mut stream, _) = listener.accept().unwrap();
338                let mut request = [0_u8; 4096];
339                let _ = stream.read(&mut request);
340                let response = format!(
341                    "HTTP/1.1 {status_line}\r\nContent-Length: {}\r\nConnection: close\r\n{header_block}\r\n{body}",
342                    body.as_bytes().len()
343                );
344
345                stream.write_all(response.as_bytes()).unwrap();
346                stream.flush().unwrap();
347                let _ = stream.shutdown(Shutdown::Both);
348            });
349            let url = Url::parse(&format!("http://{address}{path}")).unwrap();
350
351            Self {
352                url,
353                handle: Some(handle),
354            }
355        }
356
357        fn url(&self) -> &Url {
358            &self.url
359        }
360    }
361
362    impl Drop for TestHttpServer {
363        fn drop(&mut self) {
364            if let Some(handle) = self.handle.take() {
365                handle.join().unwrap();
366            }
367        }
368    }
369}