Skip to main content

github_mcp/services/
api_client.rs

1// GitHub v3 REST API MCP server — generated by mcpify. Do not hand-edit.
2//
3// Generic operationId -> HTTP request dispatcher (rather than one method
4// per operation), so generation stays robust against specs with hundreds
5// of operations and this file's size never grows with the spec.
6
7use 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
44/// Substitutes every `{name}` path template segment with the matching
45/// argument, percent-encoded — mirrors `targets::typescript`'s
46/// regex-based `applyPathParams`, done here with a manual scan rather
47/// than a `regex`-crate dependency this codebase otherwise has no use for.
48fn 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    /// Executes `endpoint` against the configured target API, retrying
108    /// transient failures up to `config.retry_attempts` times, all inside
109    /// the circuit breaker and rate limiter (REQ-2.3.3). `args` may
110    /// contain a `body` key holding the JSON request body, alongside any
111    /// path/query/header parameter values.
112    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                // Some APIs (e.g. Spotify's) 411 on a body-less PUT/POST/DELETE
191                // with no Content-Length header — reqwest/hyper treats a
192                // zero-length body the same as no body and still omits the
193                // header on its own, so it has to be set explicitly.
194                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}