1use std::collections::HashMap;
8use std::time::Duration;
9
10use serde_json::{Map, Value};
11
12use crate::auth::auth_manager::AuthManager;
13use crate::auth::request_credentials::RequestCredentials;
14use crate::core::api_url_builder::build_api_url;
15use crate::core::circuit_breaker::{CircuitBreaker, CircuitBreakerError};
16use crate::core::config_schema::Config;
17use crate::core::rate_limiter::RateLimiter;
18use crate::data::store::EndpointRecord;
19
20fn parameters_of(endpoint: &EndpointRecord) -> Vec<Value> {
21 endpoint
22 .input_schema
23 .get("parameters")
24 .and_then(Value::as_array)
25 .cloned()
26 .unwrap_or_default()
27}
28
29fn names_with_location(endpoint: &EndpointRecord, location: &str) -> Vec<String> {
30 parameters_of(endpoint)
31 .iter()
32 .filter(|param| param.get("in").and_then(Value::as_str) == Some(location))
33 .filter_map(|param| param.get("name").and_then(Value::as_str).map(String::from))
34 .collect()
35}
36
37fn value_to_plain_string(value: &Value) -> String {
38 match value {
39 Value::String(s) => s.clone(),
40 other => other.to_string(),
41 }
42}
43
44fn apply_path_params(endpoint: &EndpointRecord, args: &Map<String, Value>) -> String {
49 let mut result = String::new();
50 let mut chars = endpoint.path.chars().peekable();
51 while let Some(c) = chars.next() {
52 if c != '{' {
53 result.push(c);
54 continue;
55 }
56 let mut name = String::new();
57 for next in chars.by_ref() {
58 if next == '}' {
59 break;
60 }
61 name.push(next);
62 }
63 let value = args
64 .get(&name)
65 .map(value_to_plain_string)
66 .unwrap_or_default();
67 result.push_str(
68 &percent_encoding::utf8_percent_encode(&value, percent_encoding::NON_ALPHANUMERIC)
69 .to_string(),
70 );
71 }
72 result
73}
74
75fn pick_by_location(
76 endpoint: &EndpointRecord,
77 args: &Map<String, Value>,
78 location: &str,
79) -> Vec<(String, String)> {
80 names_with_location(endpoint, location)
81 .into_iter()
82 .filter_map(|name| {
83 args.get(&name)
84 .map(|value| (name.clone(), value_to_plain_string(value)))
85 })
86 .collect()
87}
88
89pub struct ApiClient {
90 config: Config,
91 client: reqwest::Client,
92 circuit_breaker: CircuitBreaker,
93 rate_limiter: RateLimiter,
94}
95
96impl ApiClient {
97 pub fn new(config: Config) -> Self {
98 let rate_limiter = RateLimiter::new(config.rate_limit as usize, Duration::from_secs(1));
99 Self {
100 client: reqwest::Client::new(),
101 circuit_breaker: CircuitBreaker::default(),
102 rate_limiter,
103 config,
104 }
105 }
106
107 pub async fn execute(
113 &self,
114 endpoint: &EndpointRecord,
115 args: &Value,
116 auth_manager: &mut AuthManager,
117 request_override: Option<&RequestCredentials>,
118 ) -> anyhow::Result<Value> {
119 self.rate_limiter.acquire()?;
120
121 let empty = Map::new();
122 let args_map = args.as_object().unwrap_or(&empty);
123
124 let query = pick_by_location(endpoint, args_map, "query");
125 let path = apply_path_params(endpoint, args_map);
126 let url = build_api_url(&self.config.url, &path, &query)?;
127
128 let mut headers: HashMap<String, String> = HashMap::new();
129 headers.insert("Content-Type".to_string(), "application/json".to_string());
130
131 headers.insert(
132 "Accept".to_string(),
133 "application/vnd.github+json".to_string(),
134 );
135
136 headers.insert(
137 "User-Agent".to_string(),
138 "mcpify-client/0.1.0 (generated by mcpify)".to_string(),
139 );
140
141 for (name, value) in pick_by_location(endpoint, args_map, "header") {
142 headers.insert(name, value);
143 }
144 let headers = auth_manager
145 .apply_auth_headers(
146 headers,
147 &endpoint.method,
148 &url,
149 self.config.transport,
150 request_override,
151 )
152 .await?;
153
154 let body = args_map.get("body").cloned();
155
156 match self
157 .circuit_breaker
158 .execute(|| self.dispatch(&endpoint.method, &url, body.as_ref(), &headers))
159 .await
160 {
161 Ok(value) => Ok(value),
162 Err(CircuitBreakerError::Open) => anyhow::bail!("circuit breaker is open"),
163 Err(CircuitBreakerError::Inner(err)) => Err(err),
164 }
165 }
166
167 async fn dispatch(
168 &self,
169 method: &str,
170 url: &str,
171 body: Option<&Value>,
172 headers: &HashMap<String, String>,
173 ) -> anyhow::Result<Value> {
174 let parsed_method = reqwest::Method::from_bytes(method.as_bytes())?;
175
176 let mut attempt = 0u32;
177 loop {
178 let mut request = self
179 .client
180 .request(parsed_method.clone(), url)
181 .timeout(Duration::from_millis(self.config.timeout_ms));
182 for (key, value) in headers {
183 request = request.header(key, value);
184 }
185 if let Some(body) = body {
186 request = request.json(body);
187 } else if parsed_method != reqwest::Method::GET
188 && parsed_method != reqwest::Method::HEAD
189 {
190 request = request
195 .header(reqwest::header::CONTENT_LENGTH, "0")
196 .body(Vec::new());
197 }
198
199 match request.send().await {
200 Ok(response) => {
201 let value = response
202 .error_for_status()?
203 .json::<Value>()
204 .await
205 .unwrap_or(Value::Null);
206 return Ok(value);
207 }
208 Err(err) => {
209 attempt += 1;
210 if attempt > self.config.retry_attempts {
211 return Err(err.into());
212 }
213 }
214 }
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use std::sync::{Arc, Mutex};
222
223 use tokio::io::{AsyncReadExt, AsyncWriteExt};
224 use tokio::net::TcpListener;
225
226 use super::*;
227
228 async fn mock_http(
229 status: &'static str,
230 body: &'static str,
231 ) -> (String, Arc<Mutex<String>>, tokio::task::JoinHandle<()>) {
232 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
233 let address = listener.local_addr().unwrap();
234 let captured = Arc::new(Mutex::new(String::new()));
235 let request = captured.clone();
236 let handle = tokio::spawn(async move {
237 let (mut stream, _) = listener.accept().await.unwrap();
238 let mut bytes = Vec::new();
239 let mut buffer = [0u8; 4096];
240 loop {
241 let read = stream.read(&mut buffer).await.unwrap();
242 if read == 0 {
243 break;
244 }
245 bytes.extend_from_slice(&buffer[..read]);
246 let headers_end = bytes
247 .windows(4)
248 .position(|window| window == b"\r\n\r\n")
249 .map(|index| index + 4);
250 if let Some(headers_end) = headers_end {
251 let headers = String::from_utf8_lossy(&bytes[..headers_end]);
252 let content_length = headers
253 .lines()
254 .find_map(|line| {
255 line.to_ascii_lowercase()
256 .strip_prefix("content-length:")
257 .and_then(|value| value.trim().parse::<usize>().ok())
258 })
259 .unwrap_or(0);
260 if bytes.len() >= headers_end + content_length {
261 break;
262 }
263 }
264 }
265 *request.lock().unwrap() = String::from_utf8_lossy(&bytes).into_owned();
266 let wire = format!(
267 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
268 body.len()
269 );
270 stream.write_all(wire.as_bytes()).await.unwrap();
271 });
272 (format!("http://{address}"), captured, handle)
273 }
274
275 async fn disconnecting_server() -> String {
276 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
277 let address = listener.local_addr().unwrap();
278 tokio::spawn(async move {
279 for _ in 0..4 {
280 let Ok((stream, _)) = listener.accept().await else {
281 break;
282 };
283 drop(stream);
284 }
285 });
286 format!("http://{address}")
287 }
288
289 fn client(url: String, retry_attempts: u32) -> ApiClient {
290 let config: Config = serde_json::from_value(serde_json::json!({
291 "url": url,
292
293 "auth_method": "pat",
294
295 "retry_attempts": retry_attempts,
296 "timeout_ms": 200
297 }))
298 .unwrap();
299 ApiClient::new(config)
300 }
301
302 fn endpoint(path: &str, input_schema: Value) -> EndpointRecord {
303 EndpointRecord {
304 operation_id: "op".to_string(),
305 path: path.to_string(),
306 method: "GET".to_string(),
307 summary: None,
308 description: None,
309 input_schema,
310 output_schema: Value::Null,
311 auth_scheme_ref: None,
312 }
313 }
314
315 #[test]
316 fn substitutes_path_parameters() {
317 let endpoint = endpoint("/widgets/{id}", Value::Null);
318 let args = Map::from_iter([("id".to_string(), Value::String("abc 123".to_string()))]);
319 assert_eq!(apply_path_params(&endpoint, &args), "/widgets/abc%20123");
320 }
321
322 #[test]
323 fn leaves_a_path_without_placeholders_untouched() {
324 let endpoint = endpoint("/widgets", Value::Null);
325 assert_eq!(apply_path_params(&endpoint, &Map::new()), "/widgets");
326 }
327
328 #[test]
329 fn picks_query_parameters_by_declared_location() {
330 let endpoint = endpoint(
331 "/widgets",
332 serde_json::json!({
333 "parameters": [
334 { "in": "query", "name": "limit" },
335 { "in": "header", "name": "X-Trace-Id" },
336 ]
337 }),
338 );
339 let args = Map::from_iter([
340 ("limit".to_string(), serde_json::json!(10)),
341 ("X-Trace-Id".to_string(), Value::String("abc".to_string())),
342 ]);
343
344 let query = pick_by_location(&endpoint, &args, "query");
345 assert_eq!(query, vec![("limit".to_string(), "10".to_string())]);
346
347 let headers = pick_by_location(&endpoint, &args, "header");
348 assert_eq!(headers, vec![("X-Trace-Id".to_string(), "abc".to_string())]);
349 }
350
351 #[tokio::test]
352 async fn dispatch_sends_json_and_empty_bodies_and_parses_responses() {
353 let (url, request, server) = mock_http("200 OK", r#"{"ok":true}"#).await;
354 let response = client(url.clone(), 0)
355 .dispatch(
356 "POST",
357 &url,
358 Some(&serde_json::json!({ "name": "coverage" })),
359 &HashMap::from([("X-Coverage".to_string(), "yes".to_string())]),
360 )
361 .await
362 .unwrap();
363 assert_eq!(response, serde_json::json!({ "ok": true }));
364 server.await.unwrap();
365 {
366 let request = request.lock().unwrap();
367 assert!(request.contains(r#"{"name":"coverage"}"#));
368 assert!(request.to_ascii_lowercase().contains("x-coverage: yes"));
369 }
370
371 let (url, request, server) = mock_http("204 No Content", "").await;
372 let response = client(url.clone(), 0)
373 .dispatch("DELETE", &url, None, &HashMap::new())
374 .await
375 .unwrap();
376 assert_eq!(response, Value::Null);
377 server.await.unwrap();
378 assert!(
379 request
380 .lock()
381 .unwrap()
382 .to_ascii_lowercase()
383 .contains("content-length: 0")
384 );
385 }
386
387 #[tokio::test]
388 async fn dispatch_surfaces_method_status_and_retry_exhaustion_errors() {
389 let local_url = disconnecting_server().await;
390 let invalid_method = client(local_url.clone(), 0)
391 .dispatch("NOT A METHOD", &local_url, None, &HashMap::new())
392 .await;
393 assert!(invalid_method.is_err());
394
395 let (url, _, server) = mock_http("500 Internal Server Error", "{}").await;
396 assert!(
397 client(url.clone(), 0)
398 .dispatch("GET", &url, None, &HashMap::new())
399 .await
400 .is_err()
401 );
402 server.await.unwrap();
403
404 let local_url = disconnecting_server().await;
405 assert!(
406 client(local_url.clone(), 1)
407 .dispatch("GET", &local_url, None, &HashMap::new())
408 .await
409 .is_err()
410 );
411 }
412}