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}