Skip to main content

ferro_api_mcp/
http.rs

1use std::time::Duration;
2
3use serde_json::Value;
4use url::Url;
5
6use crate::error::Error;
7use crate::types::{ApiOperation, ApiParam, ParamLocation};
8
9/// HTTP client for executing API calls against the target service.
10pub struct HttpClient {
11    client: reqwest::Client,
12    base_url: Url,
13    api_key: Option<String>,
14}
15
16impl HttpClient {
17    /// Creates a new `HttpClient` with a 30-second request timeout.
18    pub fn new(base_url: Url, api_key: Option<String>) -> Self {
19        let client = reqwest::Client::builder()
20            .timeout(Duration::from_secs(30))
21            .build()
22            .expect("failed to build reqwest client");
23
24        Self {
25            client,
26            base_url,
27            api_key,
28        }
29    }
30
31    /// Executes an API operation with the given arguments.
32    ///
33    /// Handles path interpolation, query parameters, request body, and
34    /// authorization headers. Returns the parsed JSON response or an error.
35    pub async fn execute(
36        &self,
37        op: &ApiOperation,
38        args: &serde_json::Map<String, Value>,
39    ) -> Result<Value, Error> {
40        let path = interpolate_path(&op.path, &op.parameters, args);
41
42        let url = self
43            .base_url
44            .join(&path)
45            .map_err(|e| Error::HttpClient(format!("invalid URL: {e}")))?;
46
47        let method: reqwest::Method = op
48            .method
49            .to_uppercase()
50            .parse()
51            .map_err(|e| Error::HttpClient(format!("invalid HTTP method: {e}")))?;
52
53        let url_str = url.as_str().to_string();
54        let mut request = self.client.request(method.clone(), url);
55
56        if let Some(key) = &self.api_key {
57            request = request.header("Authorization", format!("Bearer {key}"));
58        }
59
60        let query_params = build_query_params(&op.parameters, args);
61        if !query_params.is_empty() {
62            request = request.query(&query_params);
63        }
64
65        let is_body_method = matches!(
66            method,
67            reqwest::Method::POST | reqwest::Method::PUT | reqwest::Method::PATCH
68        );
69        if is_body_method {
70            if let Some(body) = args.get("body") {
71                request = request.json(body);
72            }
73        }
74
75        let response = request.send().await.map_err(|e| {
76            if e.is_connect() {
77                Error::HttpClient(format!(
78                    "cannot connect to API at {url_str}. Is the server running? ({e})"
79                ))
80            } else if e.is_timeout() {
81                Error::HttpClient(format!(
82                    "request to {url_str} timed out. The API may be slow or overloaded."
83                ))
84            } else {
85                Error::HttpClient(e.to_string())
86            }
87        })?;
88
89        let status = response.status();
90        let body_text = response
91            .text()
92            .await
93            .map_err(|e| Error::HttpClient(e.to_string()))?;
94
95        if status.is_success() {
96            match serde_json::from_str(&body_text) {
97                Ok(json) => Ok(json),
98                Err(_) => Ok(Value::String(body_text)),
99            }
100        } else {
101            let suggestion = match status.as_u16() {
102                401 => " Check the --api-key flag.",
103                403 => " The API key may lack permissions for this operation.",
104                404 => " The endpoint may not exist. Verify the API is running and the spec is current.",
105                422 => " The request body may have validation errors. Check the required fields.",
106                429 => " Rate limited. Wait before retrying.",
107                500..=599 => " The API is experiencing server errors.",
108                _ => "",
109            };
110            Err(Error::ApiError {
111                status: status.as_u16(),
112                body: format!("{body_text}{suggestion}"),
113            })
114        }
115    }
116}
117
118/// Replaces `{param_name}` placeholders in the path with values from `args`.
119///
120/// Only parameters with `location == Path` are interpolated. String values are
121/// used directly (without surrounding quotes); other types are converted via
122/// `to_string()`. Missing parameters leave the placeholder unchanged.
123pub fn interpolate_path(
124    path: &str,
125    params: &[ApiParam],
126    args: &serde_json::Map<String, Value>,
127) -> String {
128    let mut result = path.to_string();
129
130    for param in params {
131        if param.location != ParamLocation::Path {
132            continue;
133        }
134        if let Some(value) = args.get(&param.name) {
135            let replacement = match value {
136                Value::String(s) => s.clone(),
137                other => other.to_string(),
138            };
139            result = result.replace(&format!("{{{}}}", param.name), &replacement);
140        }
141    }
142
143    result
144}
145
146/// Extracts query parameters from `args` for params with `location == Query`.
147///
148/// Returns key-value pairs suitable for `reqwest::RequestBuilder::query()`.
149/// String values are used directly; other types are converted via `to_string()`.
150pub fn build_query_params(
151    params: &[ApiParam],
152    args: &serde_json::Map<String, Value>,
153) -> Vec<(String, String)> {
154    let mut query = Vec::new();
155
156    for param in params {
157        if param.location != ParamLocation::Query {
158            continue;
159        }
160        if let Some(value) = args.get(&param.name) {
161            let str_value = match value {
162                Value::String(s) => s.clone(),
163                other => other.to_string(),
164            };
165            query.push((param.name.clone(), str_value));
166        }
167    }
168
169    query
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use serde_json::json;
176
177    fn path_param(name: &str) -> ApiParam {
178        ApiParam {
179            name: name.to_string(),
180            location: ParamLocation::Path,
181            required: true,
182            schema: json!({"type": "string"}),
183            description: None,
184        }
185    }
186
187    fn query_param(name: &str) -> ApiParam {
188        ApiParam {
189            name: name.to_string(),
190            location: ParamLocation::Query,
191            required: false,
192            schema: json!({"type": "string"}),
193            description: None,
194        }
195    }
196
197    #[test]
198    fn interpolate_path_single_param() {
199        let params = vec![path_param("id")];
200        let mut args = serde_json::Map::new();
201        args.insert("id".to_string(), json!("123"));
202
203        let result = interpolate_path("/users/{id}", &params, &args);
204        assert_eq!(result, "/users/123");
205    }
206
207    #[test]
208    fn interpolate_path_multiple_params() {
209        let params = vec![path_param("user_id"), path_param("post_id")];
210        let mut args = serde_json::Map::new();
211        args.insert("user_id".to_string(), json!("abc"));
212        args.insert("post_id".to_string(), json!("456"));
213
214        let result = interpolate_path("/users/{user_id}/posts/{post_id}", &params, &args);
215        assert_eq!(result, "/users/abc/posts/456");
216    }
217
218    #[test]
219    fn interpolate_path_numeric_value() {
220        let params = vec![path_param("id")];
221        let mut args = serde_json::Map::new();
222        args.insert("id".to_string(), json!(42));
223
224        let result = interpolate_path("/users/{id}", &params, &args);
225        assert_eq!(result, "/users/42");
226    }
227
228    #[test]
229    fn interpolate_path_missing_param() {
230        let params = vec![path_param("id")];
231        let args = serde_json::Map::new();
232
233        let result = interpolate_path("/users/{id}", &params, &args);
234        assert_eq!(result, "/users/{id}");
235    }
236
237    #[test]
238    fn build_query_params_extracts_query_only() {
239        let params = vec![path_param("id"), query_param("page"), query_param("limit")];
240        let mut args = serde_json::Map::new();
241        args.insert("id".to_string(), json!("123"));
242        args.insert("page".to_string(), json!("2"));
243        args.insert("limit".to_string(), json!(50));
244
245        let result = build_query_params(&params, &args);
246        assert_eq!(result.len(), 2);
247        assert!(result.contains(&("page".to_string(), "2".to_string())));
248        assert!(result.contains(&("limit".to_string(), "50".to_string())));
249    }
250
251    #[test]
252    fn build_query_params_empty_when_no_query_params() {
253        let params = vec![path_param("id")];
254        let mut args = serde_json::Map::new();
255        args.insert("id".to_string(), json!("123"));
256
257        let result = build_query_params(&params, &args);
258        assert!(result.is_empty());
259    }
260
261    #[test]
262    fn build_query_params_skips_absent_args() {
263        let params = vec![query_param("page"), query_param("limit")];
264        let mut args = serde_json::Map::new();
265        args.insert("page".to_string(), json!("1"));
266        // limit not in args
267
268        let result = build_query_params(&params, &args);
269        assert_eq!(result.len(), 1);
270        assert_eq!(result[0], ("page".to_string(), "1".to_string()));
271    }
272}