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
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}