// file: crates/ksp-offchain-transport-lib/src/http_client.rs // version: 4 //! Crate-wide hardened REST client used internally by off-chain capability adapters. /// Bounded successful JSON document returned by the crate-internal REST client. pub(crate) struct HttpJsonDocument { bytes: std::vec::Vec, } impl crate::HttpJsonDocument { /// Returns the validated raw JSON bytes for provider-specific typed deserialization. #[must_use] pub(crate) fn as_bytes(&self) -> &[u8] { return self.bytes.as_slice(); } } impl std::fmt::Debug for crate::HttpJsonDocument { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { return formatter.debug_struct("HttpJsonDocument").field("byte_len", &self.bytes.len()).finish(); } } /// Crate-internal fixed-origin GET request. /// /// URLs and headers are deliberately absent from [`std::fmt::Debug`] because future provider adapters can attach credentials to headers. pub(crate) struct HttpGetRequest { headers: reqwest::header::HeaderMap, url: reqwest::Url, } impl crate::HttpGetRequest { /// Creates one HTTPS GET request from a crate-owned official provider URL. pub(crate) fn new_https(url: &'static str) -> ksp_core_lib::Result { return Self::parse(url, false); } /// Appends one validated non-secret path segment to a crate-owned official provider base URL. pub(crate) fn append_path_segment(&mut self, value: &str) -> ksp_core_lib::Result<()> { let segments_result = self.url.path_segments_mut(); let mut segments = match segments_result { std::result::Result::Ok(value) => value, std::result::Result::Err(()) => { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot accept a path segment") .with_context("field", "provider_url_path"), ); }, }; segments.push(value); return std::result::Result::Ok(()); } /// Appends one non-secret query pair using URL encoding. pub(crate) fn append_query_pair(&mut self, name: &'static str, value: &str) { self.url.query_pairs_mut().append_pair(name, value); return; } /// Adds one sensitive header without exposing its value through this type's debug representation. pub(crate) fn insert_sensitive_header(&mut self, name: &'static str, value: &str) -> ksp_core_lib::Result<()> { let name_result = reqwest::header::HeaderName::from_bytes(name.as_bytes()); let name = match name_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header name is invalid") .with_context("field", "header_name") .with_source(error), ); }, }; let value_result = reqwest::header::HeaderValue::from_bytes(value.as_bytes()); let mut value = match value_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header value is invalid") .with_context("field", "header_value") .with_source(error), ); }, }; value.set_sensitive(true); self.headers.insert(name, value); return std::result::Result::Ok(()); } /// Reports whether a named header is present without exposing its value in tests. #[cfg(test)] pub(crate) fn has_header_for_test(&self, name: &'static str) -> bool { return self.headers.contains_key(name); } /// Creates a plain-HTTP request for loopback-only deterministic unit tests. #[cfg(test)] pub(crate) fn new_test_http(url: &str) -> ksp_core_lib::Result { return Self::parse(url, true); } /// Returns the constructed URL only to deterministic in-crate tests; production diagnostics remain redacted. #[cfg(test)] pub(crate) fn url_for_test(&self) -> &reqwest::Url { return &self.url; } fn parse(url: &str, allow_http_for_tests: bool) -> ksp_core_lib::Result { let parsed_result = reqwest::Url::parse(url); let parsed = match parsed_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL is invalid") .with_context("field", "provider_url") .with_source(error), ); }, }; let scheme_allowed = parsed.scheme() == "https" || (allow_http_for_tests && parsed.scheme() == "http"); if !scheme_allowed || parsed.host_str().is_none() { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL must use an allowed scheme and host") .with_context("field", "provider_url"), ); } if !parsed.username().is_empty() || parsed.password().is_some() { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot embed credentials") .with_context("field", "provider_url"), ); } return std::result::Result::Ok(Self { headers: reqwest::header::HeaderMap::new(), url: parsed }); } } impl std::fmt::Debug for crate::HttpGetRequest { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { return formatter.write_str("HttpGetRequest()"); } } /// Shareable hardened REST client owned by Off-chain Transport. #[derive(Clone)] pub(crate) struct HttpRestClient { client: reqwest::Client, settings: crate::HttpClientSettings, } impl crate::HttpRestClient { /// Builds one hardened client with redirects, system proxies and reqwest automatic retries disabled. pub(crate) fn new(settings: crate::HttpClientSettings) -> ksp_core_lib::Result { let client_result = reqwest::Client::builder() .connect_timeout(settings.connect_timeout()) .timeout(settings.request_timeout()) .redirect(reqwest::redirect::Policy::none()) .referer(false) .retry(reqwest::retry::never()) .no_proxy() .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) .build(); let client = match client_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED, "Off-chain HTTP client could not be initialized") .with_source(error.without_url()), ); }, }; ksp_logging_lib::debug!( target: crate::TRACING_TARGET, connect_timeout_ms = duration_millis_u64(settings.connect_timeout()), request_timeout_ms = duration_millis_u64(settings.request_timeout()), max_response_body_bytes = settings.max_response_body_bytes(), "created hardened off-chain HTTP REST client" ); return std::result::Result::Ok(Self { client, settings }); } /// Executes one GET request and returns only a bounded syntactically valid JSON document. pub(crate) async fn get_json( &self, provider: &'static str, operation: &'static str, request: crate::HttpGetRequest, ) -> ksp_core_lib::Result { let send_result = self.client.get(request.url).headers(request.headers).send().await; let mut response = match send_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)), }; let status = response.status().as_u16(); let retry_after = parse_retry_after(response.headers()); if !(200..300).contains(&status) { return classify_http_status(provider, operation, status, retry_after); } if let std::option::Option::Some(content_length) = response.content_length() && content_length > usize_to_u64(self.settings.max_response_body_bytes()) { return response_too_large(provider, operation, self.settings.max_response_body_bytes()); } let mut body = std::vec::Vec::new(); loop { let chunk_result = response.chunk().await; let chunk = match chunk_result { std::result::Result::Ok(std::option::Option::Some(value)) => value, std::result::Result::Ok(std::option::Option::None) => break, std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)), }; let next_len = body.len().saturating_add(chunk.len()); if next_len > self.settings.max_response_body_bytes() { return response_too_large(provider, operation, self.settings.max_response_body_bytes()); } body.extend_from_slice(chunk.as_ref()); } let json_validation = serde_json::from_slice::(body.as_slice()); if let std::result::Result::Err(error) = json_validation { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_INVALID_JSON, "Off-chain provider returned invalid JSON") .with_context("provider", provider) .with_context("operation", operation) .with_source(error), ); } ksp_logging_lib::debug!( target: crate::TRACING_TARGET, provider = provider, operation = operation, http_status = status, response_body_bytes = body.len(), "completed off-chain HTTP REST request" ); return std::result::Result::Ok(crate::HttpJsonDocument { bytes: body }); } } fn classify_http_status( provider: &'static str, operation: &'static str, status: u16, retry_after: std::option::Option, ) -> ksp_core_lib::Result { if status == 401 || status == 403 { return std::result::Result::Err(http_status_error( crate::ERROR_CODE_HTTP_ACCESS_DENIED, "Off-chain provider denied HTTP access", provider, operation, status, std::option::Option::None, )); } if status == 429 { return std::result::Result::Err(http_status_error( crate::ERROR_CODE_HTTP_RATE_LIMITED, "Off-chain provider rate-limited the request", provider, operation, status, retry_after, )); } if status == 408 || (500..600).contains(&status) { return std::result::Result::Err(http_status_error( crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE, "Off-chain provider returned a temporary HTTP failure", provider, operation, status, retry_after, )); } return std::result::Result::Err(http_status_error( crate::ERROR_CODE_HTTP_REQUEST_FAILED, "Off-chain provider returned an unsuccessful HTTP status", provider, operation, status, std::option::Option::None, )); } fn http_status_error( code: ksp_core_lib::ErrorCode, message: &'static str, provider: &'static str, operation: &'static str, status: u16, retry_after: std::option::Option, ) -> ksp_core_lib::Error { let mut error = ksp_core_lib::Error::new(code, message) .with_context("provider", provider) .with_context("operation", operation) .with_context("http_status", status.to_string()); if let std::option::Option::Some(value) = retry_after { error = error.with_context("retry_after_seconds", value.as_secs().to_string()); } return error; } fn map_reqwest_error(provider: &'static str, operation: &'static str, error: reqwest::Error) -> ksp_core_lib::Error { let code = if error.is_timeout() { crate::ERROR_CODE_HTTP_TIMEOUT } else if error.is_connect() { crate::ERROR_CODE_HTTP_CONNECTION_FAILED } else { crate::ERROR_CODE_HTTP_REQUEST_FAILED }; let message = if code == crate::ERROR_CODE_HTTP_TIMEOUT { "Off-chain HTTP request timed out" } else if code == crate::ERROR_CODE_HTTP_CONNECTION_FAILED { "Off-chain HTTP connection failed" } else { "Off-chain HTTP request failed" }; return ksp_core_lib::Error::new(code, message) .with_context("provider", provider) .with_context("operation", operation) .with_source(error.without_url()); } fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> std::option::Option { let value = match headers.get(reqwest::header::RETRY_AFTER) { std::option::Option::Some(value) => value, std::option::Option::None => return std::option::Option::None, }; let text = match value.to_str() { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::option::Option::None, }; let seconds = match text.parse::() { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::option::Option::None, }; return std::option::Option::Some(std::cmp::min(std::time::Duration::from_secs(seconds), crate::HTTP_MAX_RETRY_AFTER)); } fn response_too_large(provider: &'static str, operation: &'static str, limit: usize) -> ksp_core_lib::Result { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, "Off-chain provider response exceeded the configured body limit") .with_context("provider", provider) .with_context("operation", operation) .with_context("max_response_body_bytes", limit.to_string()), ); } fn duration_millis_u64(duration: std::time::Duration) -> u64 { return match u64::try_from(duration.as_millis()) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => u64::MAX, }; } fn usize_to_u64(value: usize) -> u64 { return match u64::try_from(value) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => u64::MAX, }; } #[cfg(test)] #[path = "../unit_tests/http_client.rs"] mod tests;