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 use crate::auth::auth_strategy::Credentials;
228 use crate::core::config_schema::AuthMethod;
229 async fn mock_http(
230 status: &'static str,
231 body: &'static str,
232 ) -> (String, Arc<Mutex<String>>, tokio::task::JoinHandle<()>) {
233 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
234 let address = listener.local_addr().unwrap();
235 let captured = Arc::new(Mutex::new(String::new()));
236 let request = captured.clone();
237 let handle = tokio::spawn(async move {
238 let (mut stream, _) = listener.accept().await.unwrap();
239 let mut bytes = Vec::new();
240 let mut buffer = [0u8; 4096];
241 loop {
242 let read = stream.read(&mut buffer).await.unwrap();
243 if read == 0 {
244 break;
245 }
246 bytes.extend_from_slice(&buffer[..read]);
247 let headers_end = bytes
248 .windows(4)
249 .position(|window| window == b"\r\n\r\n")
250 .map(|index| index + 4);
251 if let Some(headers_end) = headers_end {
252 let headers = String::from_utf8_lossy(&bytes[..headers_end]);
253 let content_length = headers
254 .lines()
255 .find_map(|line| {
256 line.to_ascii_lowercase()
257 .strip_prefix("content-length:")
258 .and_then(|value| value.trim().parse::<usize>().ok())
259 })
260 .unwrap_or(0);
261 if bytes.len() >= headers_end + content_length {
262 break;
263 }
264 }
265 }
266 *request.lock().unwrap() = String::from_utf8_lossy(&bytes).into_owned();
267 let wire = format!(
268 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
269 body.len()
270 );
271 stream.write_all(wire.as_bytes()).await.unwrap();
272 });
273 (format!("http://{address}"), captured, handle)
274 }
275
276 async fn disconnecting_server() -> String {
277 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
278 let address = listener.local_addr().unwrap();
279 tokio::spawn(async move {
280 for _ in 0..4 {
281 let Ok((stream, _)) = listener.accept().await else {
282 break;
283 };
284 drop(stream);
285 }
286 });
287 format!("http://{address}")
288 }
289
290 fn client(url: String, retry_attempts: u32) -> ApiClient {
291 let config: Config = serde_json::from_value(serde_json::json!({
292 "url": url,
293
294 "auth_method": "pat",
295
296 "retry_attempts": retry_attempts,
297 "timeout_ms": 200
298 }))
299 .unwrap();
300 ApiClient::new(config)
301 }
302
303 fn endpoint(path: &str, input_schema: Value) -> EndpointRecord {
304 EndpointRecord {
305 operation_id: "op".to_string(),
306 path: path.to_string(),
307 method: "GET".to_string(),
308 summary: None,
309 description: None,
310 input_schema,
311 output_schema: Value::Null,
312 auth_scheme_ref: None,
313 }
314 }
315
316 #[test]
317 fn substitutes_path_parameters() {
318 let endpoint = endpoint("/widgets/{id}", Value::Null);
319 let args = Map::from_iter([("id".to_string(), Value::String("abc 123".to_string()))]);
320 assert_eq!(apply_path_params(&endpoint, &args), "/widgets/abc%20123");
321 }
322
323 #[test]
324 fn leaves_a_path_without_placeholders_untouched() {
325 let endpoint = endpoint("/widgets", Value::Null);
326 assert_eq!(apply_path_params(&endpoint, &Map::new()), "/widgets");
327 }
328
329 #[test]
330 fn picks_query_parameters_by_declared_location() {
331 let endpoint = endpoint(
332 "/widgets",
333 serde_json::json!({
334 "parameters": [
335 { "in": "query", "name": "limit" },
336 { "in": "header", "name": "X-Trace-Id" },
337 ]
338 }),
339 );
340 let args = Map::from_iter([
341 ("limit".to_string(), serde_json::json!(10)),
342 ("X-Trace-Id".to_string(), Value::String("abc".to_string())),
343 ]);
344
345 let query = pick_by_location(&endpoint, &args, "query");
346 assert_eq!(query, vec![("limit".to_string(), "10".to_string())]);
347
348 let headers = pick_by_location(&endpoint, &args, "header");
349 assert_eq!(headers, vec![("X-Trace-Id".to_string(), "abc".to_string())]);
350 }
351
352 #[tokio::test]
353 async fn dispatch_sends_json_and_empty_bodies_and_parses_responses() {
354 let (url, request, server) = mock_http("200 OK", r#"{"ok":true}"#).await;
355 let response = client(url.clone(), 0)
356 .dispatch(
357 "POST",
358 &url,
359 Some(&serde_json::json!({ "name": "coverage" })),
360 &HashMap::from([("X-Coverage".to_string(), "yes".to_string())]),
361 )
362 .await
363 .unwrap();
364 assert_eq!(response, serde_json::json!({ "ok": true }));
365 server.await.unwrap();
366 {
367 let request = request.lock().unwrap();
368 assert!(request.contains(r#"{"name":"coverage"}"#));
369 assert!(request.to_ascii_lowercase().contains("x-coverage: yes"));
370 }
371
372 let (url, request, server) = mock_http("204 No Content", "").await;
373 let response = client(url.clone(), 0)
374 .dispatch("DELETE", &url, None, &HashMap::new())
375 .await
376 .unwrap();
377 assert_eq!(response, Value::Null);
378 server.await.unwrap();
379 assert!(
380 request
381 .lock()
382 .unwrap()
383 .to_ascii_lowercase()
384 .contains("content-length: 0")
385 );
386 }
387
388 #[tokio::test]
389 async fn dispatch_surfaces_method_status_and_retry_exhaustion_errors() {
390 let local_url = disconnecting_server().await;
391 let invalid_method = client(local_url.clone(), 0)
392 .dispatch("NOT A METHOD", &local_url, None, &HashMap::new())
393 .await;
394 assert!(invalid_method.is_err());
395
396 let (url, _, server) = mock_http("500 Internal Server Error", "{}").await;
397 assert!(
398 client(url.clone(), 0)
399 .dispatch("GET", &url, None, &HashMap::new())
400 .await
401 .is_err()
402 );
403 server.await.unwrap();
404
405 let local_url = disconnecting_server().await;
406 assert!(
407 client(local_url.clone(), 1)
408 .dispatch("GET", &local_url, None, &HashMap::new())
409 .await
410 .is_err()
411 );
412 }
413
414 fn seeded_auth_manager() -> AuthManager {
415 let mut manager = AuthManager::new(AuthMethod::Pat);
416 manager.set_credentials(Credentials::from([(
417 "token".to_string(),
418 "s3cr3t".to_string(),
419 )]));
420 manager
421 }
422
423 #[tokio::test]
424 async fn execute_builds_the_url_applies_auth_and_parses_the_response() {
425 let (url, request, server) = mock_http("200 OK", r#"{"ok":true}"#).await;
426 let api_client = client(url.clone(), 0);
427 let endpoint = EndpointRecord {
428 operation_id: "op".to_string(),
429 path: "/widgets/{id}".to_string(),
430 method: "GET".to_string(),
431 summary: None,
432 description: None,
433 input_schema: serde_json::json!({
434 "parameters": [
435 { "in": "path", "name": "id" },
436 { "in": "query", "name": "limit" },
437 ]
438 }),
439 output_schema: Value::Null,
440 auth_scheme_ref: None,
441 };
442 let args = serde_json::json!({ "id": "abc 123", "limit": 5 });
443 let mut auth_manager = seeded_auth_manager();
444
445 let response = api_client
446 .execute(&endpoint, &args, &mut auth_manager, None)
447 .await
448 .unwrap();
449 assert_eq!(response, serde_json::json!({ "ok": true }));
450 server.await.unwrap();
451
452 let request = request.lock().unwrap();
453 assert!(request.contains("GET /widgets/abc%20123?limit=5 HTTP/1.1"));
454 assert!(
455 request
456 .to_ascii_lowercase()
457 .contains("authorization: bearer s3cr3t")
458 );
459 }
460
461 #[tokio::test]
462 async fn execute_surfaces_a_dispatch_error_through_the_circuit_breaker() {
463 let local_url = disconnecting_server().await;
464 let api_client = client(local_url.clone(), 0);
465 let endpoint = endpoint("/widgets", Value::Null);
466 let mut auth_manager = seeded_auth_manager();
467
468 let result = api_client
469 .execute(&endpoint, &Value::Null, &mut auth_manager, None)
470 .await;
471 assert!(result.is_err());
472 }
473}