Skip to main content

turbomcp_openapi/
parser.rs

1//! OpenAPI specification parsing.
2
3use std::path::Path;
4use std::time::Duration;
5
6use openapiv3::OpenAPI;
7use url::Url;
8
9use crate::error::{OpenApiError, Result};
10use crate::security::validate_url_for_ssrf;
11
12const DEFAULT_SPEC_FETCH_TIMEOUT_SECS: u64 = 30;
13
14/// Parse an OpenAPI specification from a string.
15///
16/// Tries JSON first when the content starts with `{`, but falls back to YAML
17/// on JSON failure — flow-style YAML documents (e.g. `{key: value}`) also
18/// start with `{` and would otherwise be misclassified as malformed JSON.
19pub fn parse_spec(content: &str) -> Result<OpenAPI> {
20    if content.trim_start().starts_with('{') {
21        match serde_json::from_str::<OpenAPI>(content) {
22            Ok(spec) => return Ok(spec),
23            Err(json_err) => {
24                // Flow-style YAML (`{key: value}`) parses as bad JSON; try
25                // YAML before surfacing the JSON diagnostic.
26                if let Ok(spec) = serde_norway::from_str::<OpenAPI>(content) {
27                    return Ok(spec);
28                }
29                return Err(json_err.into());
30            }
31        }
32    }
33
34    serde_norway::from_str(content).map_err(Into::into)
35}
36
37/// Load an OpenAPI specification from a file.
38pub fn load_from_file(path: &Path) -> Result<OpenAPI> {
39    let content = std::fs::read_to_string(path)?;
40    parse_spec(&content)
41}
42
43/// Fetch an OpenAPI specification from a URL.
44pub async fn fetch_from_url(url: &str) -> Result<OpenAPI> {
45    let url = Url::parse(url)?;
46    validate_url_for_ssrf(&url)?;
47
48    let client = reqwest::Client::builder()
49        .timeout(Duration::from_secs(DEFAULT_SPEC_FETCH_TIMEOUT_SECS))
50        .build()?;
51    let response = client.get(url).send().await?;
52
53    if !response.status().is_success() {
54        return Err(OpenApiError::ApiError(format!(
55            "HTTP {} fetching OpenAPI spec",
56            response.status()
57        )));
58    }
59
60    let content = response.text().await?;
61    parse_spec(&content)
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    const SIMPLE_SPEC_JSON: &str = r#"{
69        "openapi": "3.0.0",
70        "info": {
71            "title": "Test API",
72            "version": "1.0.0"
73        },
74        "paths": {
75            "/users": {
76                "get": {
77                    "summary": "List users",
78                    "responses": {
79                        "200": {
80                            "description": "Success"
81                        }
82                    }
83                }
84            }
85        }
86    }"#;
87
88    const SIMPLE_SPEC_YAML: &str = r#"
89openapi: "3.0.0"
90info:
91  title: Test API
92  version: "1.0.0"
93paths:
94  /users:
95    get:
96      summary: List users
97      responses:
98        "200":
99          description: Success
100"#;
101
102    #[test]
103    fn test_parse_json() {
104        let spec = parse_spec(SIMPLE_SPEC_JSON).unwrap();
105        assert_eq!(spec.info.title, "Test API");
106        assert!(spec.paths.paths.contains_key("/users"));
107    }
108
109    #[test]
110    fn test_parse_yaml() {
111        let spec = parse_spec(SIMPLE_SPEC_YAML).unwrap();
112        assert_eq!(spec.info.title, "Test API");
113        assert!(spec.paths.paths.contains_key("/users"));
114    }
115
116    #[test]
117    fn test_invalid_spec() {
118        let result = parse_spec("not valid openapi");
119        assert!(result.is_err());
120    }
121
122    #[tokio::test]
123    async fn test_fetch_from_url_blocks_localhost_before_request() {
124        let result = fetch_from_url("http://127.0.0.1:9/openapi.json").await;
125        assert!(matches!(result, Err(OpenApiError::SsrfBlocked(_))));
126    }
127}