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