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 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?;
116 Ok(yaml_value_to_json(yaml_value))
117}
118
119pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
121 match serde_json::from_str::<serde_json::Value>(content) {
123 Ok(v) => Ok(v),
124 Err(e) => {
125 let err_msg = e.to_string();
126 if err_msg.contains("number out of range") {
127 let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?;
129 Ok(yaml_value_to_json(yaml_value))
130 } else {
131 Err(e.into())
132 }
133 }
134 }
135}
136
137fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value {
138 match yaml {
139 serde_yaml::Value::Null => serde_json::Value::Null,
140 serde_yaml::Value::Bool(b) => serde_json::Value::Bool(b),
141 serde_yaml::Value::Number(n) => {
142 if let Some(i) = n.as_i64() {
143 serde_json::Value::Number(i.into())
144 } else if let Some(u) = n.as_u64() {
145 serde_json::Value::Number(u.into())
146 } else if let Some(f) = n.as_f64() {
147 serde_json::json!(f)
148 } else {
149 serde_json::json!(0.0)
151 }
152 }
153 serde_yaml::Value::String(s) => serde_json::Value::String(s),
154 serde_yaml::Value::Sequence(seq) => {
155 serde_json::Value::Array(seq.into_iter().map(yaml_value_to_json).collect())
156 }
157 serde_yaml::Value::Mapping(map) => {
158 let obj = map
159 .into_iter()
160 .filter_map(|(k, v)| {
161 let key = match k {
162 serde_yaml::Value::String(s) => s,
163 serde_yaml::Value::Number(n) => n.to_string(),
164 serde_yaml::Value::Bool(b) => b.to_string(),
165 _ => return None,
166 };
167 Some((key, yaml_value_to_json(v)))
168 })
169 .collect();
170 serde_json::Value::Object(obj)
171 }
172 serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(tagged.value),
173 }
174}
175
176fn sanitize_large_yaml_integers(content: &str) -> String {
180 let mut result = String::with_capacity(content.len());
181 for line in content.lines() {
182 if let Some(sanitized) = try_sanitize_integer_line(line) {
183 result.push_str(&sanitized);
184 } else {
185 result.push_str(line);
186 }
187 result.push('\n');
188 }
189 result
190}
191
192fn try_sanitize_integer_line(line: &str) -> Option<String> {
195 let trimmed = line.trim();
198
199 if trimmed.is_empty() || trimmed.starts_with('#') {
201 return None;
202 }
203
204 let colon_pos = line.find(": ")?;
206 let value_start = colon_pos + 2;
207 let value_str = line[value_start..].trim();
208
209 if value_str.is_empty() {
211 return None;
212 }
213
214 let (is_negative, digit_part) = if let Some(rest) = value_str.strip_prefix('-') {
215 (true, rest)
216 } else {
217 (false, value_str)
218 };
219
220 if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
222 return None;
223 }
224
225 let overflows = if is_negative {
227 digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
229 } else {
230 digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
232 };
233
234 if overflows {
235 let mut sanitized = line[..value_start].to_string();
237 sanitized.push_str(value_str);
238 sanitized.push_str(".0");
239 Some(sanitized)
240 } else {
241 None
242 }
243}
244
245pub fn validate_oas_document(value: &serde_json::Value) -> Result<Option<String>, String> {
250 let version = value
251 .get("openapi")
252 .and_then(|value| value.as_str())
253 .unwrap_or("");
254 match parse_oas_version(version) {
255 Some((3, 0 | 1)) => Ok(None),
256 Some((3, 2)) => Ok(Some(format!(
257 "OpenAPI {version} support is experimental; some 3.2-only features are not generated"
258 ))),
259 Some((major, minor)) => Err(format!(
260 "unsupported OpenAPI version {major}.{minor} ({version:?}); expected 3.0, 3.1, or experimental 3.2"
261 )),
262 None => {
263 let hint = if value.get("swagger").is_some() {
264 " (the document appears to be Swagger 2.0)"
265 } else {
266 ""
267 };
268 Err(format!("missing or unrecognized `openapi` version{hint}"))
269 }
270 }
271}