httpgenerator_openapi/
format.rs1use 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}