1use std::time::Duration;
2
3use serde_json::Value;
4use url::Url;
5
6use crate::error::Error;
7use crate::types::{ApiOperation, ApiParam, ParamLocation};
8
9pub struct HttpClient {
11 client: reqwest::Client,
12 base_url: Url,
13 api_key: Option<String>,
14}
15
16impl HttpClient {
17 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 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
118pub 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(¶m.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
146pub 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(¶m.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}", ¶ms, &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}", ¶ms, &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}", ¶ms, &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}", ¶ms, &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(¶ms, &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(¶ms, &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 let result = build_query_params(¶ms, &args);
269 assert_eq!(result.len(), 1);
270 assert_eq!(result[0], ("page".to_string(), "1".to_string()));
271 }
272}