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        for (name, value) in pick_by_location(endpoint, args_map, "header") {
131            headers.insert(name, value);
132        }
133        let headers = auth_manager
134            .apply_auth_headers(
135                headers,
136                &endpoint.method,
137                &url,
138                self.config.transport,
139                request_override,
140            )
141            .await?;
142
143        let body = args_map.get("body").cloned();
144
145        match self
146            .circuit_breaker
147            .execute(|| self.dispatch(&endpoint.method, &url, body.as_ref(), &headers))
148            .await
149        {
150            Ok(value) => Ok(value),
151            Err(CircuitBreakerError::Open) => anyhow::bail!("circuit breaker is open"),
152            Err(CircuitBreakerError::Inner(err)) => Err(err),
153        }
154    }
155
156    async fn dispatch(
157        &self,
158        method: &str,
159        url: &str,
160        body: Option<&Value>,
161        headers: &HashMap<String, String>,
162    ) -> anyhow::Result<Value> {
163        let parsed_method = reqwest::Method::from_bytes(method.as_bytes())?;
164
165        let mut attempt = 0u32;
166        loop {
167            let mut request = self
168                .client
169                .request(parsed_method.clone(), url)
170                .timeout(Duration::from_millis(self.config.timeout_ms));
171            for (key, value) in headers {
172                request = request.header(key, value);
173            }
174            if let Some(body) = body {
175                request = request.json(body);
176            }
177
178            match request.send().await {
179                Ok(response) => {
180                    let value = response
181                        .error_for_status()?
182                        .json::<Value>()
183                        .await
184                        .unwrap_or(Value::Null);
185                    return Ok(value);
186                }
187                Err(err) => {
188                    attempt += 1;
189                    if attempt > self.config.retry_attempts {
190                        return Err(err.into());
191                    }
192                }
193            }
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn endpoint(path: &str, input_schema: Value) -> EndpointRecord {
203        EndpointRecord {
204            operation_id: "op".to_string(),
205            path: path.to_string(),
206            method: "GET".to_string(),
207            summary: None,
208            description: None,
209            input_schema,
210            output_schema: Value::Null,
211            auth_scheme_ref: None,
212        }
213    }
214
215    #[test]
216    fn substitutes_path_parameters() {
217        let endpoint = endpoint("/widgets/{id}", Value::Null);
218        let args = Map::from_iter([("id".to_string(), Value::String("abc 123".to_string()))]);
219        assert_eq!(apply_path_params(&endpoint, &args), "/widgets/abc%20123");
220    }
221
222    #[test]
223    fn leaves_a_path_without_placeholders_untouched() {
224        let endpoint = endpoint("/widgets", Value::Null);
225        assert_eq!(apply_path_params(&endpoint, &Map::new()), "/widgets");
226    }
227
228    #[test]
229    fn picks_query_parameters_by_declared_location() {
230        let endpoint = endpoint(
231            "/widgets",
232            serde_json::json!({
233                "parameters": [
234                    { "in": "query", "name": "limit" },
235                    { "in": "header", "name": "X-Trace-Id" },
236                ]
237            }),
238        );
239        let args = Map::from_iter([
240            ("limit".to_string(), serde_json::json!(10)),
241            ("X-Trace-Id".to_string(), Value::String("abc".to_string())),
242        ]);
243
244        let query = pick_by_location(&endpoint, &args, "query");
245        assert_eq!(query, vec![("limit".to_string(), "10".to_string())]);
246
247        let headers = pick_by_location(&endpoint, &args, "header");
248        assert_eq!(headers, vec![("X-Trace-Id".to_string(), "abc".to_string())]);
249    }
250}