Skip to main content

httpgenerator_openapi/
format.rs

1use std::{fmt, path::Path};
2
3use crate::{ContentFormatDetectionError, OpenApiSource};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum OpenApiContentFormat {
7    Json,
8    Yaml,
9}
10
11impl OpenApiContentFormat {
12    pub const fn as_str(self) -> &'static str {
13        match self {
14            Self::Json => "JSON",
15            Self::Yaml => "YAML",
16        }
17    }
18
19    pub fn from_path(path: impl AsRef<Path>) -> Option<Self> {
20        let extension = path.as_ref().extension()?.to_str()?;
21
22        Self::from_extension(extension)
23    }
24
25    fn from_extension(extension: &str) -> Option<Self> {
26        match extension.to_ascii_lowercase().as_str() {
27            "json" => Some(Self::Json),
28            "yaml" | "yml" => Some(Self::Yaml),
29            _ => None,
30        }
31    }
32}
33
34impl fmt::Display for OpenApiContentFormat {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(self.as_str())
37    }
38}
39
40pub fn detect_content_format(
41    source: Option<&OpenApiSource>,
42    content: &str,
43) -> Result<OpenApiContentFormat, ContentFormatDetectionError> {
44    if normalized_content(content).is_empty() {
45        return Err(ContentFormatDetectionError::EmptyContent);
46    }
47
48    if let Some(format) = source.and_then(OpenApiSource::format_hint) {
49        return Ok(format);
50    }
51
52    sniff_content_format(content)
53}
54
55pub fn sniff_content_format(
56    content: &str,
57) -> Result<OpenApiContentFormat, ContentFormatDetectionError> {
58    let normalized = normalized_content(content);
59
60    if normalized.is_empty() {
61        return Err(ContentFormatDetectionError::EmptyContent);
62    }
63
64    match normalized.chars().next() {
65        Some('{') | Some('[') => Ok(OpenApiContentFormat::Json),
66        Some(_) if looks_like_yaml(normalized) => Ok(OpenApiContentFormat::Yaml),
67        _ => Err(ContentFormatDetectionError::UnknownFormat),
68    }
69}
70
71fn normalized_content(content: &str) -> &str {
72    content
73        .strip_prefix('\u{feff}')
74        .unwrap_or(content)
75        .trim_start()
76}
77
78fn looks_like_yaml(content: &str) -> bool {
79    content
80        .lines()
81        .map(|line| line.split('#').next().unwrap_or_default().trim())
82        .find(|line| !line.is_empty())
83        .is_some_and(|line| {
84            let looks_like_mapping = line.find(':').is_some_and(|index| {
85                let key = line[..index].trim();
86                let value = line[index + 1..].chars().next();
87
88                !key.is_empty() && value.map(char::is_whitespace).unwrap_or(true)
89            });
90
91            line == "---"
92                || line.starts_with("%YAML")
93                || line.starts_with("- ")
94                || looks_like_mapping
95        })
96}
97
98#[cfg(test)]
99mod tests {
100    use std::path::Path;
101
102    use crate::classify_source;
103
104    use super::{
105        ContentFormatDetectionError, OpenApiContentFormat, detect_content_format,
106        sniff_content_format,
107    };
108
109    #[test]
110    fn detects_json_from_path_extension() {
111        let format = OpenApiContentFormat::from_path(Path::new("petstore.json"));
112
113        assert_eq!(format, Some(OpenApiContentFormat::Json));
114    }
115
116    #[test]
117    fn detects_yaml_from_path_extension_case_insensitively() {
118        let format = OpenApiContentFormat::from_path(Path::new("petstore.YML"));
119
120        assert_eq!(format, Some(OpenApiContentFormat::Yaml));
121    }
122
123    #[test]
124    fn prefers_source_hint_when_available() {
125        let source = classify_source("https://example.com/openapi.yaml?download=1").unwrap();
126
127        let format = detect_content_format(Some(&source), "{\"openapi\":\"3.1.0\"}").unwrap();
128
129        assert_eq!(format, OpenApiContentFormat::Yaml);
130    }
131
132    #[test]
133    fn falls_back_to_content_sniffing_when_source_has_no_known_extension() {
134        let source = classify_source("test\\OpenAPI\\petstore").unwrap();
135
136        let format = detect_content_format(Some(&source), "{\"openapi\":\"3.1.0\"}").unwrap();
137
138        assert_eq!(format, OpenApiContentFormat::Json);
139    }
140
141    #[test]
142    fn sniffs_json_after_utf8_bom() {
143        let format = sniff_content_format("\u{feff}\n  {\"openapi\":\"3.0.0\"}").unwrap();
144
145        assert_eq!(format, OpenApiContentFormat::Json);
146    }
147
148    #[test]
149    fn sniffs_yaml_from_mapping_content() {
150        let format = sniff_content_format("openapi: 3.0.0\ninfo:\n  title: Example").unwrap();
151
152        assert_eq!(format, OpenApiContentFormat::Yaml);
153    }
154
155    #[test]
156    fn returns_empty_content_error_for_blank_input() {
157        let error = sniff_content_format("  \n\t").unwrap_err();
158
159        assert_eq!(error, ContentFormatDetectionError::EmptyContent);
160    }
161
162    #[test]
163    fn returns_unknown_format_for_unrecognized_content() {
164        let error = sniff_content_format("not a document format").unwrap_err();
165
166        assert_eq!(error, ContentFormatDetectionError::UnknownFormat);
167    }
168
169    #[test]
170    fn does_not_treat_urls_as_yaml_content() {
171        let error = sniff_content_format("https://example.com/openapi.json").unwrap_err();
172
173        assert_eq!(error, ContentFormatDetectionError::UnknownFormat);
174    }
175}