use std::time::Duration; use asupersync::http::h1::{ ClientError, HttpClient, HttpClientConfig, Method, Response as RawResponse, }; use asupersync::http::pool::PoolConfig; use asupersync::time::{timeout, wall_now}; use serde::Serialize; use serde::de::DeserializeOwned; use crate::core::error::{LoreError, NetworkErrorKind, Result}; const MAX_RESPONSE_BODY_BYTES: usize = 64 * 1024 * 1024; // 64 MiB pub struct Client { inner: HttpClient, timeout: Duration, } #[derive(Debug)] pub struct Response { pub status: u16, pub reason: String, pub headers: Vec<(String, String)>, body: Vec, } impl Client { pub fn with_timeout(timeout: Duration) -> Self { Self { inner: HttpClient::with_config(HttpClientConfig { pool_config: PoolConfig { max_connections_per_host: 6, max_total_connections: 100, idle_timeout: Duration::from_secs(90), ..Default::default() }, ..Default::default() }), timeout, } } pub async fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result { self.execute(Method::Get, url, headers, Vec::new()).await } pub async fn get_with_query( &self, url: &str, params: &[(&str, String)], headers: &[(&str, &str)], ) -> Result { let full_url = append_query_params(url, params); self.execute(Method::Get, &full_url, headers, Vec::new()) .await } pub async fn post_json( &self, url: &str, headers: &[(&str, &str)], body: &T, ) -> Result { let body_bytes = serde_json::to_vec(body) .map_err(|e| LoreError::Other(format!("JSON serialization failed: {e}")))?; let mut all_headers: Vec<(&str, &str)> = headers.to_vec(); all_headers.push(("Content-Type", "application/json")); self.execute(Method::Post, url, &all_headers, body_bytes) .await } async fn execute( &self, method: Method, url: &str, headers: &[(&str, &str)], body: Vec, ) -> Result { let header_tuples: Vec<(String, String)> = headers .iter() .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) .collect(); let raw: RawResponse = timeout( wall_now(), self.timeout, self.inner.request(method, url, header_tuples, body), ) .await .map_err(|_| LoreError::GitLabNetworkError { base_url: url.to_string(), kind: NetworkErrorKind::Timeout, detail: Some(format!("Request timed out after {:?}", self.timeout)), })? .map_err(|e| LoreError::GitLabNetworkError { base_url: url.to_string(), kind: classify_transport_error(&e), detail: Some(format!("{e:?}")), })?; if raw.body.len() > MAX_RESPONSE_BODY_BYTES { return Err(LoreError::Other(format!( "Response body too large: {} bytes (max {MAX_RESPONSE_BODY_BYTES})", raw.body.len(), ))); } Ok(Response { status: raw.status, reason: raw.reason, headers: raw.headers, body: raw.body, }) } } impl Response { pub fn is_success(&self) -> bool { (200..300).contains(&self.status) } pub fn json(&self) -> Result { serde_json::from_slice(&self.body) .map_err(|e| LoreError::Other(format!("JSON parse error: {e}"))) } pub fn text(self) -> Result { String::from_utf8(self.body) .map_err(|e| LoreError::Other(format!("UTF-8 decode error: {e}"))) } pub fn header(&self, name: &str) -> Option<&str> { self.headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case(name)) .map(|(_, v)| v.as_str()) } pub fn headers_all(&self, name: &str) -> Vec<&str> { self.headers .iter() .filter(|(k, _)| k.eq_ignore_ascii_case(name)) .map(|(_, v)| v.as_str()) .collect() } } fn classify_transport_error(e: &ClientError) -> NetworkErrorKind { match e { ClientError::DnsError(_) => NetworkErrorKind::DnsResolution, ClientError::ConnectError(_) => NetworkErrorKind::ConnectionRefused, ClientError::TlsError(_) => NetworkErrorKind::Tls, _ => NetworkErrorKind::Other, } } fn append_query_params(url: &str, params: &[(&str, String)]) -> String { if params.is_empty() { return url.to_string(); } let query: String = params .iter() .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) .collect::>() .join("&"); let (base, fragment) = match url.split_once('#') { Some((b, f)) => (b, Some(f)), None => (url, None), }; let with_query = if base.contains('?') { format!("{base}&{query}") } else { format!("{base}?{query}") }; match fragment { Some(f) => format!("{with_query}#{f}"), None => with_query, } } #[cfg(test)] mod tests { use super::*; #[test] fn append_query_params_empty_returns_unchanged() { let url = "https://example.com/api"; assert_eq!(append_query_params(url, &[]), url); } #[test] fn append_query_params_adds_question_mark() { let result = append_query_params( "https://example.com/api", &[("page", "1".into()), ("per_page", "20".into())], ); assert_eq!(result, "https://example.com/api?page=1&per_page=20"); } #[test] fn append_query_params_existing_query_uses_ampersand() { let result = append_query_params( "https://example.com/api?state=opened", &[("page", "2".into())], ); assert_eq!(result, "https://example.com/api?state=opened&page=2"); } #[test] fn append_query_params_preserves_fragment() { let result = append_query_params("https://example.com/api#section", &[("key", "val".into())]); assert_eq!(result, "https://example.com/api?key=val#section"); } #[test] fn append_query_params_encodes_special_chars() { let result = append_query_params("https://example.com/api", &[("labels[]", "bug fix".into())]); assert_eq!(result, "https://example.com/api?labels%5B%5D=bug%20fix"); } #[test] fn append_query_params_repeated_keys() { let result = append_query_params( "https://example.com/api", &[("labels[]", "bug".into()), ("labels[]", "urgent".into())], ); assert_eq!( result, "https://example.com/api?labels%5B%5D=bug&labels%5B%5D=urgent" ); } #[test] fn response_header_case_insensitive() { let resp = Response { status: 200, reason: "OK".into(), headers: vec![ ("Content-Type".into(), "application/json".into()), ("X-Page".into(), "1".into()), ], body: Vec::new(), }; assert_eq!(resp.header("content-type"), Some("application/json")); assert_eq!(resp.header("CONTENT-TYPE"), Some("application/json")); assert_eq!(resp.header("Content-Type"), Some("application/json")); assert_eq!(resp.header("x-page"), Some("1")); assert_eq!(resp.header("X-Missing"), None); } #[test] fn response_headers_all_returns_multiple_values() { let resp = Response { status: 200, reason: "OK".into(), headers: vec![ ("Link".into(), "; rel=\"next\"".into()), ("Link".into(), "; rel=\"last\"".into()), ("Content-Type".into(), "application/json".into()), ], body: Vec::new(), }; let links = resp.headers_all("link"); assert_eq!(links.len(), 2); assert_eq!(links[0], "; rel=\"next\""); assert_eq!(links[1], "; rel=\"last\""); } #[test] fn response_is_success_range() { for status in [200, 201, 204, 299] { let resp = Response { status, reason: String::new(), headers: Vec::new(), body: Vec::new(), }; assert!(resp.is_success(), "status {status} should be success"); } for status in [100, 199, 300, 301, 400, 404, 500] { let resp = Response { status, reason: String::new(), headers: Vec::new(), body: Vec::new(), }; assert!(!resp.is_success(), "status {status} should not be success"); } } #[test] fn classify_dns_error() { let err = ClientError::DnsError(std::io::Error::other("dns failed")); assert_eq!( classify_transport_error(&err), NetworkErrorKind::DnsResolution ); } #[test] fn classify_connect_error() { let err = ClientError::ConnectError(std::io::Error::other("refused")); assert_eq!( classify_transport_error(&err), NetworkErrorKind::ConnectionRefused ); } #[test] fn classify_tls_error() { let err = ClientError::TlsError("bad cert".into()); assert_eq!(classify_transport_error(&err), NetworkErrorKind::Tls); } }