Skip to main content

openapi_to_rust/
spec_source.rs

1//! Spec-source policy and document parsing shared by the CLI, library
2//! consumers, and the WASM playground build.
3//!
4//! Everything in this module is pure: URL policy checks build on `url::Url`
5//! and document parsing goes through `serde_yaml`/`serde_json` in memory. The
6//! I/O that actually fetches or reads a spec lives in [`crate::cli`], which is
7//! gated behind the `cli` feature.
8
9/// Whether an input string names a supported remote OpenAPI source.
10pub fn is_remote_spec(input: &str) -> bool {
11    url::Url::parse(input).is_ok_and(|url| matches!(url.scheme(), "https" | "http"))
12}
13
14/// Parse and enforce the remote-source transport policy.
15pub 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
34/// Remove URL credentials, query strings, and fragments before recording a
35/// source label in generated code. Local paths are retained as supplied.
36pub 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
76/// Parse the `openapi` version string into (major, minor). Tolerates patch and
77/// build-metadata suffixes. Returns None for unrecognised input.
78pub 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    // Determine format from extension or content
95    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
109/// Parse YAML to serde_json::Value, converting large numbers to f64 to avoid overflow.
110/// serde_yaml 0.9 cannot represent integers exceeding i64/u64 range (e.g. numbers > 2^64),
111/// so we preprocess the YAML to convert such numbers to float notation, then go through
112/// serde_yaml::Value and convert to serde_json::Value manually.
113pub 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            // Real-world specs (Adyen, Amadeus) carry literal tab characters
119            // inside block-scalar prose. YAML 1.2 forbids tabs for
120            // indentation, and serde_yaml rejects the document outright even
121            // when the tab is content. Retry once with tabs sanitized
122            // line-wise so block-scalar indentation survives.
123            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
133/// Parse JSON with lossy number handling: numbers that overflow i64/u64 are stored as f64.
134pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
135    // Try normal parsing first (fast path)
136    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                // Fall back: parse via YAML which handles large numbers
142                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                // Fallback: represent as 0.0
164                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
190/// Sanitize tab characters so serde_yaml accepts the document: a tab on an
191/// otherwise whitespace-only line is dropped entirely (blank lines carry no
192/// indentation semantics in block scalars), while tabs adjacent to content
193/// become a single space. This keeps block-scalar indent auto-detection
194/// consistent with sibling prose lines regardless of tab-stop assumptions.
195fn expand_yaml_tabs(content: &str) -> String {
196    content
197        .lines()
198        .map(|line| {
199            if line.chars().all(|ch| ch == ' ' || ch == '\t') {
200                // Whitespace-only line: drop tabs, keep the spaces.
201                line.replace('\t', "")
202            } else {
203                line.replace('\t', " ")
204            }
205        })
206        .collect::<Vec<_>>()
207        .join("\n")
208}
209
210/// Preprocess YAML content to convert integers that exceed i64/u64 range to float notation.
211/// serde_yaml 0.9 cannot parse integers larger than u64::MAX or smaller than i64::MIN,
212/// so we find bare integer values on YAML lines and append `.0` if they overflow.
213fn 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
226/// If a YAML line has a `key: <integer>` pattern where the integer overflows i64/u64,
227/// convert it to float by appending `.0`. Returns None if no change needed.
228fn try_sanitize_integer_line(line: &str) -> Option<String> {
229    // Match pattern: optional whitespace, key, colon, space(s), then a number value
230    // We look for the value portion after the last `: ` or `- ` on the line
231    let trimmed = line.trim();
232
233    // Skip comments and empty lines
234    if trimmed.is_empty() || trimmed.starts_with('#') {
235        return None;
236    }
237
238    // Find the value part — after `: ` for mapping entries
239    let colon_pos = line.find(": ")?;
240    let value_start = colon_pos + 2;
241    let value_str = line[value_start..].trim();
242
243    // Check if the value looks like a bare integer (optional leading minus, then digits)
244    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    // Must be all digits
255    if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
256        return None;
257    }
258
259    // Check if it overflows i64/u64
260    let overflows = if is_negative {
261        // Check if |value| > i64::MAX + 1 = 9223372036854775808
262        digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
263    } else {
264        // Check if value > u64::MAX = 18446744073709551615
265        digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
266    };
267
268    if overflows {
269        // Replace the integer with float notation
270        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
279/// Validate the `openapi` version field of a parsed document.
280///
281/// Returns an optional warning for experimental versions (3.2) and an error
282/// for unsupported or missing versions.
283pub 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}