365 lines
15 KiB
Rust
365 lines
15 KiB
Rust
// 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<u8>,
|
|
}
|
|
|
|
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<Self> {
|
|
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<Self> {
|
|
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<Self> {
|
|
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(<redacted>)");
|
|
}
|
|
}
|
|
|
|
/// 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<Self> {
|
|
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<crate::HttpJsonDocument> {
|
|
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::<serde::de::IgnoredAny>(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<std::time::Duration>,
|
|
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
|
|
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<std::time::Duration>,
|
|
) -> 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<std::time::Duration> {
|
|
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::<u64>() {
|
|
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<crate::HttpJsonDocument> {
|
|
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;
|