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    let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?;
116    Ok(yaml_value_to_json(yaml_value))
117}
118
119/// Parse JSON with lossy number handling: numbers that overflow i64/u64 are stored as f64.
120pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
121    // Try normal parsing first (fast path)
122    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                // Fall back: parse via YAML which handles large numbers
128                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                // Fallback: represent as 0.0
150                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
176/// Preprocess YAML content to convert integers that exceed i64/u64 range to float notation.
177/// serde_yaml 0.9 cannot parse integers larger than u64::MAX or smaller than i64::MIN,
178/// so we find bare integer values on YAML lines and append `.0` if they overflow.
179fn 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
192/// If a YAML line has a `key: <integer>` pattern where the integer overflows i64/u64,
193/// convert it to float by appending `.0`. Returns None if no change needed.
194fn try_sanitize_integer_line(line: &str) -> Option<String> {
195    // Match pattern: optional whitespace, key, colon, space(s), then a number value
196    // We look for the value portion after the last `: ` or `- ` on the line
197    let trimmed = line.trim();
198
199    // Skip comments and empty lines
200    if trimmed.is_empty() || trimmed.starts_with('#') {
201        return None;
202    }
203
204    // Find the value part — after `: ` for mapping entries
205    let colon_pos = line.find(": ")?;
206    let value_start = colon_pos + 2;
207    let value_str = line[value_start..].trim();
208
209    // Check if the value looks like a bare integer (optional leading minus, then digits)
210    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    // Must be all digits
221    if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
222        return None;
223    }
224
225    // Check if it overflows i64/u64
226    let overflows = if is_negative {
227        // Check if |value| > i64::MAX + 1 = 9223372036854775808
228        digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
229    } else {
230        // Check if value > u64::MAX = 18446744073709551615
231        digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
232    };
233
234    if overflows {
235        // Replace the integer with float notation
236        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
245/// Validate the `openapi` version field of a parsed document.
246///
247/// Returns an optional warning for experimental versions (3.2) and an error
248/// for unsupported or missing versions.
249pub 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}