Skip to main content

rest_e2e_mcp/http/
client.rs

1use std::collections::HashMap;
2use std::time::Instant;
3
4use crate::types::HttpResponse;
5
6/// HTTPリクエスト実行時のエラー種別。
7#[derive(Debug, Clone, thiserror::Error)]
8pub enum RequestError {
9    /// タイムアウト(制限時間超過)。
10    #[error("Timeout after {elapsed_ms}ms (limit: {limit_ms}ms)")]
11    Timeout { elapsed_ms: u64, limit_ms: u64 },
12    /// TCP接続エラー(拒否、リセット等)。
13    #[error("Connection error: {0}")]
14    Connection(String),
15    /// DNS解決失敗。
16    #[error("DNS resolution failed: {0}")]
17    Dns(String),
18    /// TLS/SSLエラー。
19    #[error("TLS error: {0}")]
20    Tls(String),
21    /// その他のエラー。
22    #[error("Request error: {0}")]
23    Other(String),
24}
25
26impl RequestError {
27    /// エラー種別を短縮キーとして返す(circuit breaker 用)。
28    pub fn error_key(&self) -> &'static str {
29        match self {
30            Self::Timeout { .. } => "timeout",
31            Self::Connection(_) => "connection",
32            Self::Dns(_) => "dns",
33            Self::Tls(_) => "tls",
34            Self::Other(_) => "other",
35        }
36    }
37
38    /// reqwest::Error を分類して RequestError に変換する。
39    fn classify(err: reqwest::Error, elapsed_ms: u64, limit_ms: u64) -> Self {
40        if err.is_timeout() {
41            return Self::Timeout {
42                elapsed_ms,
43                limit_ms,
44            };
45        }
46        if err.is_connect() {
47            return Self::Connection(err.without_url().to_string());
48        }
49        let msg_lower = err.to_string().to_lowercase();
50        if msg_lower.contains("dns") || msg_lower.contains("resolve") {
51            return Self::Dns(err.without_url().to_string());
52        }
53        if msg_lower.contains("tls")
54            || msg_lower.contains("ssl")
55            || msg_lower.contains("certificate")
56        {
57            return Self::Tls(err.without_url().to_string());
58        }
59        Self::Other(err.without_url().to_string())
60    }
61}
62
63/// 共有HTTPクライアントを構築する。スイート内で再利用してコネクションプールを活用する。
64pub fn build_client() -> Result<reqwest::Client, RequestError> {
65    reqwest::Client::builder()
66        .redirect(reqwest::redirect::Policy::limited(10))
67        .build()
68        .map_err(|e| RequestError::Other(format!("Failed to build HTTP client: {e}")))
69}
70
71/// HTTPリクエストを実行する。
72pub async fn execute_request(
73    client: &reqwest::Client,
74    method: &str,
75    url: &str,
76    headers: &HashMap<String, String>,
77    body: Option<&str>,
78    timeout_ms: u64,
79) -> Result<HttpResponse, RequestError> {
80    let method = method
81        .parse::<reqwest::Method>()
82        .map_err(|e| RequestError::Other(format!("Invalid HTTP method: {e}")))?;
83
84    let mut req = client.request(method, url);
85
86    for (key, val) in headers {
87        req = req.header(key.as_str(), val.as_str());
88    }
89
90    if let Some(body) = body {
91        req = req.body(body.to_string());
92    }
93
94    // build() で実際のリクエストオブジェクトを生成し、送信ヘッダーを取得
95    let built = req
96        .build()
97        .map_err(|e| RequestError::Other(format!("Failed to build request: {e}")))?;
98
99    let mut actual_request_headers: HashMap<String, String> = HashMap::new();
100    for (key, val) in built.headers() {
101        if let Ok(v) = val.to_str() {
102            actual_request_headers.insert(key.as_str().to_string(), v.to_string());
103        }
104    }
105
106    let start = Instant::now();
107    let timeout_duration = std::time::Duration::from_millis(timeout_ms);
108
109    let response = tokio::time::timeout(timeout_duration, client.execute(built))
110        .await
111        .map_err(|_| RequestError::Timeout {
112            elapsed_ms: elapsed_ms_saturating(&start),
113            limit_ms: timeout_ms,
114        })?
115        .map_err(|e| RequestError::classify(e, elapsed_ms_saturating(&start), timeout_ms))?;
116
117    let elapsed_ms = elapsed_ms_saturating(&start);
118
119    let status = response.status().as_u16();
120    let http_version = match response.version() {
121        reqwest::Version::HTTP_09 => "HTTP/0.9",
122        reqwest::Version::HTTP_10 => "HTTP/1.0",
123        reqwest::Version::HTTP_11 => "HTTP/1.1",
124        reqwest::Version::HTTP_2 => "HTTP/2",
125        reqwest::Version::HTTP_3 => "HTTP/3",
126        _ => "HTTP/?",
127    }
128    .to_string();
129
130    // レスポンスヘッダー収集
131    let mut resp_headers: HashMap<String, String> = HashMap::new();
132    for (key, val) in response.headers() {
133        if let Ok(v) = val.to_str() {
134            resp_headers.insert(key.as_str().to_string(), v.to_string());
135        }
136    }
137
138    // charset検出
139    let charset = detect_charset(&resp_headers);
140
141    // ボディ取得・エンコーディング変換
142    let bytes = response
143        .bytes()
144        .await
145        .map_err(|e| RequestError::Other(format!("Failed to read response body: {e}")))?;
146    let size_bytes = bytes.len();
147
148    let body = decode_body(&bytes, charset.as_deref());
149
150    Ok(HttpResponse {
151        status,
152        http_version,
153        headers: resp_headers,
154        actual_request_headers,
155        body,
156        charset,
157        elapsed_ms,
158        size_bytes,
159    })
160}
161
162/// `Instant::elapsed()` を `u64` ミリ秒に安全変換する。
163fn elapsed_ms_saturating(start: &Instant) -> u64 {
164    start.elapsed().as_millis().min(u64::MAX as u128) as u64
165}
166
167/// Content-Typeヘッダーからcharsetを検出する。
168fn detect_charset(headers: &HashMap<String, String>) -> Option<String> {
169    let ct = headers
170        .iter()
171        .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
172        .map(|(_, v)| v)?;
173
174    ct.split(';').find_map(|part| {
175        let part = part.trim();
176        part.strip_prefix("charset=")
177            .map(|charset| charset.trim_matches('"').to_lowercase())
178    })
179}
180
181/// バイト列を指定charsetでUTF-8に変換する。
182fn decode_body(bytes: &[u8], charset: Option<&str>) -> String {
183    let charset = charset.unwrap_or("utf-8");
184
185    match charset {
186        "utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(),
187        _ => {
188            let encoding =
189                encoding_rs::Encoding::for_label(charset.as_bytes()).unwrap_or(encoding_rs::UTF_8);
190            let (decoded, _, _) = encoding.decode(bytes);
191            decoded.into_owned()
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn detect_charset_from_content_type() {
202        let mut headers = HashMap::new();
203        headers.insert(
204            "content-type".to_string(),
205            "text/csv; charset=Shift_JIS".to_string(),
206        );
207        assert_eq!(detect_charset(&headers), Some("shift_jis".to_string()));
208    }
209
210    #[test]
211    fn detect_charset_missing() {
212        let headers = HashMap::new();
213        assert_eq!(detect_charset(&headers), None);
214    }
215
216    #[test]
217    fn detect_charset_no_charset_param() {
218        let mut headers = HashMap::new();
219        headers.insert("content-type".to_string(), "application/json".to_string());
220        assert_eq!(detect_charset(&headers), None);
221    }
222
223    #[test]
224    fn decode_utf8() {
225        let bytes = "こんにちは".as_bytes();
226        assert_eq!(decode_body(bytes, Some("utf-8")), "こんにちは");
227    }
228
229    #[test]
230    fn decode_shift_jis() {
231        let (encoded, _, _) = encoding_rs::SHIFT_JIS.encode("テスト");
232        let decoded = decode_body(&encoded, Some("shift_jis"));
233        assert_eq!(decoded, "テスト");
234    }
235
236    #[test]
237    fn request_error_timeout_display() {
238        let err = RequestError::Timeout {
239            elapsed_ms: 30019,
240            limit_ms: 30000,
241        };
242        assert_eq!(err.to_string(), "Timeout after 30019ms (limit: 30000ms)");
243        assert_eq!(err.error_key(), "timeout");
244    }
245
246    #[test]
247    fn request_error_connection_display() {
248        let err = RequestError::Connection("connection refused".to_string());
249        assert_eq!(err.to_string(), "Connection error: connection refused");
250        assert_eq!(err.error_key(), "connection");
251    }
252
253    #[test]
254    fn request_error_dns_display() {
255        let err = RequestError::Dns("failed to lookup address".to_string());
256        assert_eq!(
257            err.to_string(),
258            "DNS resolution failed: failed to lookup address"
259        );
260        assert_eq!(err.error_key(), "dns");
261    }
262
263    #[test]
264    fn request_error_tls_display() {
265        let err = RequestError::Tls("certificate verify failed".to_string());
266        assert_eq!(err.to_string(), "TLS error: certificate verify failed");
267        assert_eq!(err.error_key(), "tls");
268    }
269
270    #[test]
271    fn request_error_other_display() {
272        let err = RequestError::Other("something went wrong".to_string());
273        assert_eq!(err.to_string(), "Request error: something went wrong");
274        assert_eq!(err.error_key(), "other");
275    }
276
277    #[test]
278    fn elapsed_ms_saturating_normal() {
279        let start = Instant::now();
280        let ms = elapsed_ms_saturating(&start);
281        assert!(ms < 1000);
282    }
283}