openapi_to_rust/
spec_source.rs1pub fn is_remote_spec(input: &str) -> bool {
11 url::Url::parse(input).is_ok_and(|url| matches!(url.scheme(), "https" | "http"))
12}
13
14pub fn validate_remote_spec_url(input: &str) -> Result<url::Url, String> {
16 let url =
17 url::Url::parse(input).map_err(|error| format!("invalid remote OpenAPI URL: {error}"))?;
18 if !url.username().is_empty() || url.password().is_some() {
19 return Err("remote OpenAPI URLs must not contain embedded credentials".to_string());
20 }
21 match url.scheme() {
22 "https" => Ok(url),
23 "http" if is_loopback_host(url.host_str()) => Ok(url),
24 "http" => Err(
25 "remote OpenAPI URLs must use HTTPS (plain HTTP is allowed only for localhost/loopback)"
26 .to_string(),
27 ),
28 scheme => Err(format!(
29 "unsupported OpenAPI URL scheme `{scheme}`; use HTTPS or a local file path"
30 )),
31 }
32}
33
34pub fn sanitize_source_provenance(input: &str) -> String {
37 let sanitize_controls = |value: &str| {
38 value
39 .chars()
40 .map(|character| {
41 if character.is_control() {
42 '�'
43 } else {
44 character
45 }
46 })
47 .collect::<String>()
48 };
49 let Ok(mut url) = url::Url::parse(input) else {
50 return sanitize_controls(input);
51 };
52 if !matches!(url.scheme(), "https" | "http") {
53 return sanitize_controls(input);
54 }
55 let query_was_redacted = url.query().is_some();
56 let _ = url.set_username("");
57 let _ = url.set_password(None);
58 url.set_query(None);
59 url.set_fragment(None);
60 let mut label = url.to_string();
61 if query_was_redacted {
62 label.push_str(" (query redacted)");
63 }
64 sanitize_controls(&label)
65}
66fn is_loopback_host(host: Option<&str>) -> bool {
67 match host {
68 Some("localhost") => true,
69 Some(host) => host
70 .parse::<std::net::IpAddr>()
71 .is_ok_and(|address| address.is_loopback()),
72 None => false,
73 }
74}
75
76pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> {
79 let mut parts = s.split('.');
80 let major = parts.next()?.parse().ok()?;
81 let minor_raw = parts.next()?;
82 let minor_digits: String = minor_raw
83 .chars()
84 .take_while(|c| c.is_ascii_digit())
85 .collect();
86 let minor = minor_digits.parse().ok()?;
87 Some((major, minor))
88}
89
90pub fn parse_spec(
91 content: &str,
92 input: &str,
93) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
94 let is_yaml = input.ends_with(".yaml")
96 || input.ends_with(".yml")
97 || content.trim_start().starts_with("openapi:")
98 || content.trim_start().starts_with("swagger:");
99
100 if is_yaml {
101 let value = yaml_to_json_value(content)?;
102 Ok(value)
103 } else {
104 let value = json_from_str_lossy(content)?;
105 Ok(value)
106 }
107}
108
109pub fn yaml_to_json_value(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
114 let preprocessed = sanitize_large_yaml_integers(content);
115 match serde_yaml::from_str::<serde_yaml::Value>(&preprocessed) {
116 Ok(yaml_value) => Ok(yaml_value_to_json(yaml_value)),
117 Err(error) => {
118 if error.to_string().contains("tab character") {
124 let expanded = expand_yaml_tabs(&preprocessed);
125 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&expanded)?;
126 return Ok(yaml_value_to_json(yaml_value));
127 }
128 Err(error.into())
129 }
130 }
131}
132
133pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
135 match serde_json::from_str::<serde_json::Value>(content) {
137 Ok(v) => Ok(v),
138 Err(e) => {
139 let err_msg = e.to_string();
140 if err_msg.contains("number out of range") {
141 let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?;
143 Ok(yaml_value_to_json(yaml_value))
144 } else {
145 Err(e.into())
146 }
147 }
148 }
149}
150
151fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value {
152 match yaml {
153 serde_yaml::Value::Null => serde_json::Value::Null,
154 serde_yaml::Value::Bool(b) => serde_json::Value::Bool(b),
155 serde_yaml::Value::Number(n) => {
156 if let Some(i) = n.as_i64() {
157 serde_json::Value::Number(i.into())
158 } else if let Some(u) = n.as_u64() {
159 serde_json::Value::Number(u.into())
160 } else if let Some(f) = n.as_f64() {
161 serde_json::json!(f)
162 } else {
163 serde_json::json!(0.0)
165 }
166 }
167 serde_yaml::Value::String(s) => serde_json::Value::String(s),
168 serde_yaml::Value::Sequence(seq) => {
169 serde_json::Value::Array(seq.into_iter().map(yaml_value_to_json).collect())
170 }
171 serde_yaml::Value::Mapping(map) => {
172 let obj = map
173 .into_iter()
174 .filter_map(|(k, v)| {
175 let key = match k {
176 serde_yaml::Value::String(s) => s,
177 serde_yaml::Value::Number(n) => n.to_string(),
178 serde_yaml::Value::Bool(b) => b.to_string(),
179 _ => return None,
180 };
181 Some((key, yaml_value_to_json(v)))
182 })
183 .collect();
184 serde_json::Value::Object(obj)
185 }
186 serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(tagged.value),
187 }
188}
189
190fn expand_yaml_tabs(content: &str) -> String {
196 content
197 .lines()
198 .map(|line| {
199 if line.chars().all(|ch| ch == ' ' || ch == '\t') {
200 line.replace('\t', "")
202 } else {
203 line.replace('\t', " ")
204 }
205 })
206 .collect::<Vec<_>>()
207 .join("\n")
208}
209
210fn sanitize_large_yaml_integers(content: &str) -> String {
214 let mut result = String::with_capacity(content.len());
215 for line in content.lines() {
216 if let Some(sanitized) = try_sanitize_integer_line(line) {
217 result.push_str(&sanitized);
218 } else {
219 result.push_str(line);
220 }
221 result.push('\n');
222 }
223 result
224}
225
226fn try_sanitize_integer_line(line: &str) -> Option<String> {
229 let trimmed = line.trim();
232
233 if trimmed.is_empty() || trimmed.starts_with('#') {
235 return None;
236 }
237
238 let colon_pos = line.find(": ")?;
240 let value_start = colon_pos + 2;
241 let value_str = line[value_start..].trim();
242
243 if value_str.is_empty() {
245 return None;
246 }
247
248 let (is_negative, digit_part) = if let Some(rest) = value_str.strip_prefix('-') {
249 (true, rest)
250 } else {
251 (false, value_str)
252 };
253
254 if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
256 return None;
257 }
258
259 let overflows = if is_negative {
261 digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
263 } else {
264 digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
266 };
267
268 if overflows {
269 let mut sanitized = line[..value_start].to_string();
271 sanitized.push_str(value_str);
272 sanitized.push_str(".0");
273 Some(sanitized)
274 } else {
275 None
276 }
277}
278
279pub fn validate_oas_document(value: &serde_json::Value) -> Result<Option<String>, String> {
284 let version = value
285 .get("openapi")
286 .and_then(|value| value.as_str())
287 .unwrap_or("");
288 match parse_oas_version(version) {
289 Some((3, 0 | 1)) => Ok(None),
290 Some((3, 2)) => Ok(Some(format!(
291 "OpenAPI {version} support is experimental; some 3.2-only features are not generated"
292 ))),
293 Some((major, minor)) => Err(format!(
294 "unsupported OpenAPI version {major}.{minor} ({version:?}); expected 3.0, 3.1, or experimental 3.2"
295 )),
296 None => {
297 let hint = if value.get("swagger").is_some() {
298 " (the document appears to be Swagger 2.0)"
299 } else {
300 ""
301 };
302 Err(format!("missing or unrecognized `openapi` version{hint}"))
303 }
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn yaml_with_block_scalar_tabs_parses_after_sanitization() {
313 let spec = "openapi: 3.0.0\ninfo:\n title: tabs\n version: '1'\npaths:\n /thing:\n get:\n description: |-\n \t\n Date and time of travel.\n * Encoding: ASCII\n operationId: getThing\n responses:\n '204':\n description: ok\n";
314 let value = yaml_to_json_value(spec).expect("tabbed block scalar parses");
315 assert_eq!(
316 value["paths"]["/thing"]["get"]["operationId"],
317 serde_json::json!("getThing")
318 );
319 let description = value["paths"]["/thing"]["get"]["description"]
320 .as_str()
321 .expect("description is a string");
322 assert!(
323 description.contains("Date and time of travel."),
324 "{description}"
325 );
326 }
327
328 #[test]
329 fn expand_yaml_tabs_drops_whitespace_only_line_tabs() {
330 assert_eq!(expand_yaml_tabs(" \t"), " ");
331 assert_eq!(expand_yaml_tabs("a\tb"), "a b");
332 assert_eq!(expand_yaml_tabs("no tabs"), "no tabs");
333 }
334}